Skip to content
Merged
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
32 changes: 16 additions & 16 deletions packages/web-pkg/src/components/CreateLinkModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -136,29 +138,21 @@ import { storeToRefs } from 'pinia'

type RoleRef = ComponentPublicInstance<typeof OcButton>

interface CallbackArgs {
result: PromiseSettledResult<LinkShare>[]
password: string
options?: { copyPassword?: boolean }
}

export default defineComponent({
name: 'CreateLinkModal',
components: { LinkRoleDropdown },
props: {
modal: { type: Object as PropType<Modal>, required: true },
resources: { type: Array as PropType<Resource[]>, required: true },
space: { type: Object as PropType<SpaceResource>, default: undefined },
callbackFn: {
type: Function as PropType<(args: CallbackArgs) => Promise<void> | void>,
default: undefined
}
space: { type: Object as PropType<SpaceResource>, 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 {
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ({
Expand All @@ -23,68 +16,16 @@ export const useFileActionsCreateLink = ({
} = {}) => {
const clientService = useClientService()
const userStore = useUserStore()
const { showMessage, showErrorMessage } = useMessages()
const { $gettext, $ngettext } = useGettext()
const capabilityStore = useCapabilityStore()
const ability = useAbility()
const loadingService = useLoadingService()
const { defaultLinkType } = useLinkTypes()
const { addLink } = useSharesStore()
const { dispatchModal } = useModals()
const { copyToClipboard } = useClipboard()
const { copyLink } = useCopyLink()

const proceedResult = async ({
result,
password,
options = {}
}: {
result: PromiseSettledResult<LinkShare>[]
password?: string
options?: { copyPassword?: boolean }
}) => {
const succeeded = result.filter(
(val): val is PromiseFulfilledResult<LinkShare> => 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({
Expand All @@ -95,11 +36,7 @@ export const useFileActionsCreateLink = ({
{ resourceName: resources[0].name }
),
customComponent: CreateLinkModal,
customComponentAttrs: () => ({
space,
resources,
callbackFn: proceedResult
}),
customComponentAttrs: () => ({ space, resources }),
hideActions: true
})
return
Expand All @@ -117,9 +54,9 @@ export const useFileActionsCreateLink = ({
}
})
)
const result = await loadingService.addTask(() => Promise.allSettled<LinkShare>(promises))

proceedResult({ result })
const createLinkHandler = () =>
loadingService.addTask(() => Promise.allSettled<LinkShare>(promises))
copyLink({ createLinkHandler })
}

const isVisible = ({ resources }: FileActionOptions) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/web-pkg/src/composables/clipboard/useClipboard.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/web-pkg/src/composables/links/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './useCopyLink'
export * from './useLinkTypes'
104 changes: 104 additions & 0 deletions packages/web-pkg/src/composables/links/useCopyLink.ts
Original file line number Diff line number Diff line change
@@ -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<LinkShare>[]
password?: string
}) => {
const succeeded = result.filter(
(val): val is PromiseFulfilledResult<LinkShare> => 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<PromiseSettledResult<LinkShare>[]>
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<void>((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 }
}
21 changes: 2 additions & 19 deletions packages/web-pkg/tests/unit/components/CreateLinkModal.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -97,14 +92,12 @@ describe('CreateLinkModal', () => {
})
describe('method "confirm"', () => {
it('creates links for all resources', async () => {
const callbackFn = vi.fn()
const resources = [mock<Resource>({ isFolder: false }), mock<Resource>({ 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<Resource>({ isFolder: false })]
Expand All @@ -128,13 +121,6 @@ describe('CreateLinkModal', () => {

expect(consoleMock).toHaveBeenCalledTimes(1)
})
it('calls the callback at the end if given', async () => {
const resources = [mock<Resource>({ 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', () => {
Expand All @@ -155,7 +141,6 @@ function getWrapper({
passwordEnforced = false,
passwordPolicyFulfilled = true,
embedModeEnabled = false,
callbackFn = undefined,
availableLinkTypes = [SharingLinkType.View]
}: {
resources?: Resource[]
Expand All @@ -164,7 +149,6 @@ function getWrapper({
passwordEnforced?: boolean
passwordPolicyFulfilled?: boolean
embedModeEnabled?: boolean
callbackFn?: ComponentProps<typeof CreateLinkModal>['callbackFn']
availableLinkTypes?: SharingLinkType[]
} = {}) {
vi.mocked(usePasswordPolicyService).mockReturnValue(
Expand Down Expand Up @@ -212,7 +196,6 @@ function getWrapper({
wrapper: mount(CreateLinkModal, {
props: {
resources,
callbackFn,
modal: mock<Modal>()
},
global: {
Expand Down
Loading