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) } }) })