From a2bff82840aa1675c1a0992c7e8d834d84903445 Mon Sep 17 00:00:00 2001 From: Jannik Stehle Date: Tue, 11 Nov 2025 12:03:32 +0100 Subject: [PATCH] fix: copying created links in Safari For Safari to copy a newly created link to the clipboard, it requires that the link is created within the same user interaction as the clipboard write. This refactors the copying of links to do exactly that. (cherry picked from commit c2040656250a134c4004f0aebf3d29bcbed43fd9) --- .../src/components/CreateLinkModal.vue | 32 +++--- .../files/useFileActionsCopyPermanentLink.ts | 6 +- .../actions/files/useFileActionsCreateLink.ts | 79 ++----------- .../src/composables/clipboard/useClipboard.ts | 3 + .../web-pkg/src/composables/links/index.ts | 1 + .../src/composables/links/useCopyLink.ts | 104 ++++++++++++++++++ .../unit/components/CreateLinkModal.spec.ts | 21 +--- .../useFileActionsCopyPermanentLink.spec.ts | 8 +- .../files/useFileActionsCreateLink.spec.ts | 3 - 9 files changed, 142 insertions(+), 115 deletions(-) create mode 100644 packages/web-pkg/src/composables/links/useCopyLink.ts diff --git a/packages/web-pkg/src/components/CreateLinkModal.vue b/packages/web-pkg/src/components/CreateLinkModal.vue index 79308920af..515dbfe459 100644 --- a/packages/web-pkg/src/components/CreateLinkModal.vue +++ b/packages/web-pkg/src/components/CreateLinkModal.vue @@ -125,7 +125,9 @@ import { Modal, useSharesStore, useClientService, - useThemeStore + useThemeStore, + useModals, + useCopyLink } from '../composables' import { LinkShare, SpaceResource } from '@opencloud-eu/web-client' import { Resource } from '@opencloud-eu/web-client' @@ -136,29 +138,21 @@ import { storeToRefs } from 'pinia' type RoleRef = ComponentPublicInstance -interface CallbackArgs { - result: PromiseSettledResult[] - password: string - options?: { copyPassword?: boolean } -} - export default defineComponent({ name: 'CreateLinkModal', components: { LinkRoleDropdown }, props: { modal: { type: Object as PropType, required: true }, resources: { type: Array as PropType, required: true }, - space: { type: Object as PropType, default: undefined }, - callbackFn: { - type: Function as PropType<(args: CallbackArgs) => Promise | void>, - default: undefined - } + space: { type: Object as PropType, default: undefined } }, emits: ['cancel', 'confirm'], setup(props, { expose }) { const clientService = useClientService() const language = useGettext() const { $gettext } = language + const { removeModal } = useModals() + const { copyLink } = useCopyLink() const passwordPolicyService = usePasswordPolicyService() const { isEnabled: isEmbedEnabled, postMessage } = useEmbedMode() const { @@ -252,7 +246,7 @@ export default defineComponent({ return true }) - const onConfirm = async (options: { copyPassword?: boolean } = {}) => { + const createLinkHandler = async () => { const result = await createLinks() const succeeded = result.filter(({ status }) => status === 'fulfilled') @@ -284,9 +278,15 @@ export default defineComponent({ return Promise.reject() } - if (props.callbackFn) { - props.callbackFn({ result, password: password.value, options }) - } + return result + } + + const onConfirm = async (options: { copyPassword?: boolean } = {}) => { + await copyLink({ + createLinkHandler, + password: options.copyPassword ? unref(password).value : undefined + }) + removeModal(props.modal.id) } expose({ onConfirm }) diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsCopyPermanentLink.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsCopyPermanentLink.ts index f188e41881..0e8e4006dc 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActionsCopyPermanentLink.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsCopyPermanentLink.ts @@ -1,7 +1,7 @@ import { computed } from 'vue' import { useGettext } from 'vue3-gettext' import { FileAction } from '../types' -import { useClipboard } from '../../clipboard' +import { useClipboard } from '@vueuse/core' import { useMessages } from '../../piniaStores' import { isPublicSpaceResource, isTrashResource } from '@opencloud-eu/web-client' import { useInterceptModifierClick } from '../../keyboardActions' @@ -10,12 +10,12 @@ import { FileActionOptionsWithEvent } from './useFileActions' export const useFileActionsCopyPermanentLink = () => { const { showMessage, showErrorMessage } = useMessages() const { $gettext } = useGettext() - const { copyToClipboard } = useClipboard() + const { copy } = useClipboard() const { interceptModifierClick } = useInterceptModifierClick() const copyLinkToClipboard = async (url: string) => { try { - await copyToClipboard(url) + await copy(url) showMessage({ title: $gettext('The link has been copied to your clipboard.') }) } catch (e) { showErrorMessage({ diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsCreateLink.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateLink.ts index b5334c2421..a94e1eb3c0 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActionsCreateLink.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateLink.ts @@ -4,16 +4,9 @@ import { FileAction, FileActionOptions } from '../../actions' import CreateLinkModal from '../../../components/CreateLinkModal.vue' import { useAbility } from '../../ability' import { LinkShare, isProjectSpaceResource } from '@opencloud-eu/web-client' -import { useLinkTypes } from '../../links' +import { useCopyLink, useLinkTypes } from '../../links' import { useLoadingService } from '../../loadingService' -import { - useMessages, - useModals, - useUserStore, - useCapabilityStore, - useSharesStore -} from '../../piniaStores' -import { useClipboard } from '../../clipboard' +import { useModals, useUserStore, useCapabilityStore, useSharesStore } from '../../piniaStores' import { useClientService } from '../../clientService' export const useFileActionsCreateLink = ({ @@ -23,7 +16,6 @@ export const useFileActionsCreateLink = ({ } = {}) => { const clientService = useClientService() const userStore = useUserStore() - const { showMessage, showErrorMessage } = useMessages() const { $gettext, $ngettext } = useGettext() const capabilityStore = useCapabilityStore() const ability = useAbility() @@ -31,60 +23,9 @@ export const useFileActionsCreateLink = ({ const { defaultLinkType } = useLinkTypes() const { addLink } = useSharesStore() const { dispatchModal } = useModals() - const { copyToClipboard } = useClipboard() + const { copyLink } = useCopyLink() - const proceedResult = async ({ - result, - password, - options = {} - }: { - result: PromiseSettledResult[] - password?: string - options?: { copyPassword?: boolean } - }) => { - const succeeded = result.filter( - (val): val is PromiseFulfilledResult => val.status === 'fulfilled' - ) - - if (succeeded.length) { - let successMessage = $gettext('Link has been created successfully') - - if (result.length === 1) { - // Only copy to clipboard if the user tries to create one single link - try { - const copyToClipboardText = options.copyPassword - ? $gettext( - '%{link} Password:%{password}', - { - link: succeeded[0].value.webUrl, - password - }, - true - ) - : succeeded[0].value.webUrl - - await copyToClipboard(copyToClipboardText) - successMessage = $gettext('The link has been copied to your clipboard.') - } catch (e) { - console.warn('Unable to copy link to clipboard', e) - } - } - - showMessage({ - title: $ngettext(successMessage, 'Links have been created successfully.', succeeded.length) - }) - } - - const failed = result.filter(({ status }) => status === 'rejected') - if (failed.length) { - showErrorMessage({ - errors: (failed as PromiseRejectedResult[]).map(({ reason }) => reason), - title: $ngettext('Failed to create link', 'Failed to create links', failed.length) - }) - } - } - - const handler = async ({ space, resources }: FileActionOptions) => { + const handler = ({ space, resources }: FileActionOptions) => { const passwordEnforced = capabilityStore.sharingPublicPasswordEnforcedFor.read_only === true if (enforceModal || passwordEnforced) { dispatchModal({ @@ -95,11 +36,7 @@ export const useFileActionsCreateLink = ({ { resourceName: resources[0].name } ), customComponent: CreateLinkModal, - customComponentAttrs: () => ({ - space, - resources, - callbackFn: proceedResult - }), + customComponentAttrs: () => ({ space, resources }), hideActions: true }) return @@ -117,9 +54,9 @@ export const useFileActionsCreateLink = ({ } }) ) - const result = await loadingService.addTask(() => Promise.allSettled(promises)) - - proceedResult({ result }) + const createLinkHandler = () => + loadingService.addTask(() => Promise.allSettled(promises)) + copyLink({ createLinkHandler }) } const isVisible = ({ resources }: FileActionOptions) => { diff --git a/packages/web-pkg/src/composables/clipboard/useClipboard.ts b/packages/web-pkg/src/composables/clipboard/useClipboard.ts index c46611902b..1e4ab5409b 100644 --- a/packages/web-pkg/src/composables/clipboard/useClipboard.ts +++ b/packages/web-pkg/src/composables/clipboard/useClipboard.ts @@ -1,5 +1,8 @@ import { useClipboard as _useClipboard } from '@vueuse/core' +/** + * @deprecated use useClipboard from vueuse or useCopyLink for links instead + */ export const useClipboard = () => { // doCopy creates the requested link and copies the url to the clipboard, // the copy action uses the clipboard // clipboardItem api to work around the webkit limitations. diff --git a/packages/web-pkg/src/composables/links/index.ts b/packages/web-pkg/src/composables/links/index.ts index df025c357b..b45340c22d 100644 --- a/packages/web-pkg/src/composables/links/index.ts +++ b/packages/web-pkg/src/composables/links/index.ts @@ -1 +1,2 @@ +export * from './useCopyLink' export * from './useLinkTypes' diff --git a/packages/web-pkg/src/composables/links/useCopyLink.ts b/packages/web-pkg/src/composables/links/useCopyLink.ts new file mode 100644 index 0000000000..2598342d74 --- /dev/null +++ b/packages/web-pkg/src/composables/links/useCopyLink.ts @@ -0,0 +1,104 @@ +import { useMessages } from '../piniaStores' +import { useGettext } from 'vue3-gettext' +import { LinkShare } from '@opencloud-eu/web-client' +import { useClipboard } from '@vueuse/core' + +/** + * Dedicated composable for copying created links to clipboard because it requires + * special handling for Safari. For this to work you need to pass the link create method + * to copyLink so that the link is created within the same user interaction as the clipboard write. + * + * This composable also takes care of showing success and error messages. + */ +export const useCopyLink = () => { + const { $gettext, $ngettext } = useGettext() + const { showMessage, showErrorMessage } = useMessages() + const { copy } = useClipboard() + + const getTextToCopy = ({ + result, + password + }: { + result: PromiseSettledResult[] + password?: string + }) => { + const succeeded = result.filter( + (val): val is PromiseFulfilledResult => val.status === 'fulfilled' + ) + + let copyToClipboardText = '' + if (succeeded.length) { + let successMessage = $gettext('Link has been created successfully') + + if (result.length === 1) { + // Only copy to clipboard if the user tries to create one single link + try { + copyToClipboardText = password + ? $gettext( + '%{link} Password:%{password}', + { link: succeeded[0].value.webUrl, password }, + true + ) + : succeeded[0].value.webUrl + + successMessage = $gettext('The link has been copied to your clipboard.') + } catch (e) { + console.warn('Unable to copy link to clipboard', e) + } + } + + showMessage({ + title: $ngettext(successMessage, 'Links have been created successfully.', succeeded.length) + }) + } + + const failed = result.filter(({ status }) => status === 'rejected') + if (failed.length) { + showErrorMessage({ + errors: (failed as PromiseRejectedResult[]).map(({ reason }) => reason), + title: $ngettext('Failed to create link', 'Failed to create links', failed.length) + }) + } + + return copyToClipboardText + } + + const copyLink = async ({ + createLinkHandler, + password + }: { + createLinkHandler: () => Promise[]> + password?: string + }) => { + // special handling for Safari because it doesn't allow async clipboard writes. works in most other browsers as well. + // see https://wolfgangrittner.dev/how-to-use-clipboard-api-in-safari/ and https://developer.apple.com/forums/thread/691873 + if (typeof ClipboardItem && navigator.clipboard.write) { + await new Promise((resolve, reject) => { + const text = new ClipboardItem({ + 'text/plain': createLinkHandler() + .then((result) => { + const textToCopy = getTextToCopy({ result, password }) + const blob = new Blob([textToCopy], { type: 'text/plain' }) + resolve() + return blob + }) + .catch((error) => { + reject() + throw error + }) + }) + + navigator.clipboard.write([text]) + }) + } else { + // edge case for browsers that don't support ClipboardItem (e.g. Firefox) + const result = await createLinkHandler() + const textToCopy = getTextToCopy({ result, password }) + if (textToCopy) { + copy(textToCopy) + } + } + } + + return { copyLink } +} diff --git a/packages/web-pkg/tests/unit/components/CreateLinkModal.spec.ts b/packages/web-pkg/tests/unit/components/CreateLinkModal.spec.ts index 49bea6423b..bf43724ca7 100644 --- a/packages/web-pkg/tests/unit/components/CreateLinkModal.spec.ts +++ b/packages/web-pkg/tests/unit/components/CreateLinkModal.spec.ts @@ -1,10 +1,5 @@ import CreateLinkModal from '../../../src/components/CreateLinkModal.vue' -import { - ComponentProps, - defaultComponentMocks, - defaultPlugins, - mount -} from '@opencloud-eu/web-test-helpers' +import { defaultComponentMocks, defaultPlugins, mount } from '@opencloud-eu/web-test-helpers' import { mock } from 'vitest-mock-extended' import { PasswordPolicyService } from '../../../src/services' import { usePasswordPolicyService } from '../../../src/composables/passwordPolicyService' @@ -97,14 +92,12 @@ describe('CreateLinkModal', () => { }) describe('method "confirm"', () => { it('creates links for all resources', async () => { - const callbackFn = vi.fn() const resources = [mock({ isFolder: false }), mock({ isFolder: false })] - const { wrapper } = getWrapper({ resources, callbackFn }) + const { wrapper } = getWrapper({ resources }) await wrapper.vm.onConfirm() const { addLink } = useSharesStore() expect(addLink).toHaveBeenCalledTimes(resources.length) - expect(callbackFn).toHaveBeenCalledTimes(1) }) it('emits event in embed mode including the created links', async () => { const resources = [mock({ isFolder: false })] @@ -128,13 +121,6 @@ describe('CreateLinkModal', () => { expect(consoleMock).toHaveBeenCalledTimes(1) }) - it('calls the callback at the end if given', async () => { - const resources = [mock({ isFolder: false })] - const callbackFn = vi.fn() - const { wrapper } = getWrapper({ resources, callbackFn }) - await wrapper.vm.onConfirm() - expect(callbackFn).toHaveBeenCalledTimes(1) - }) }) describe('action buttons', () => { describe('confirm button', () => { @@ -155,7 +141,6 @@ function getWrapper({ passwordEnforced = false, passwordPolicyFulfilled = true, embedModeEnabled = false, - callbackFn = undefined, availableLinkTypes = [SharingLinkType.View] }: { resources?: Resource[] @@ -164,7 +149,6 @@ function getWrapper({ passwordEnforced?: boolean passwordPolicyFulfilled?: boolean embedModeEnabled?: boolean - callbackFn?: ComponentProps['callbackFn'] availableLinkTypes?: SharingLinkType[] } = {}) { vi.mocked(usePasswordPolicyService).mockReturnValue( @@ -212,7 +196,6 @@ function getWrapper({ wrapper: mount(CreateLinkModal, { props: { resources, - callbackFn, modal: mock() }, global: { diff --git a/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsCopyPermanentLink.spec.ts b/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsCopyPermanentLink.spec.ts index d9c9e31f08..3ae8c83ef4 100644 --- a/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsCopyPermanentLink.spec.ts +++ b/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsCopyPermanentLink.spec.ts @@ -3,9 +3,9 @@ import { useFileActionsCopyPermanentLink } from '../../../../../src/composables/ import { defaultComponentMocks, getComposableWrapper } from '@opencloud-eu/web-test-helpers' import { mock } from 'vitest-mock-extended' import { Resource, SpaceResource, TrashResource } from '@opencloud-eu/web-client' -import { useClipboard } from '../../../../../src/composables/clipboard' +import { useClipboard } from '@vueuse/core' -vi.mock('../../../../../src/composables/clipboard', () => ({ +vi.mock('@vueuse/core', () => ({ useClipboard: vi.fn() })) @@ -75,7 +75,9 @@ function getWrapper({ ) => void }) { const copyToClipboardMock = vi.fn() - vi.mocked(useClipboard).mockReturnValue({ copyToClipboard: copyToClipboardMock }) + vi.mocked(useClipboard).mockReturnValue( + mock>({ copy: copyToClipboardMock }) + ) const mocks = { ...defaultComponentMocks(), copyToClipboardMock } return { diff --git a/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsCreateLink.spec.ts b/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsCreateLink.spec.ts index dc08ae3cad..dad375e07a 100644 --- a/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsCreateLink.spec.ts +++ b/packages/web-pkg/tests/unit/composables/actions/files/useFileActionsCreateLink.spec.ts @@ -1,7 +1,6 @@ import { ref, unref } from 'vue' import { useFileActionsCreateLink } from '../../../../../src/composables/actions/files/useFileActionsCreateLink' import { - useMessages, useModals, CapabilityStore, useSharesStore @@ -65,8 +64,6 @@ describe('useFileActionsCreateLink', () => { space: undefined }) expect(addLink).toHaveBeenCalledTimes(1) - const { showMessage } = useMessages() - expect(showMessage).toHaveBeenCalledTimes(1) } }) })