diff --git a/packages/design-system/package.json b/packages/design-system/package.json index f13e90fe163..369be2bcafb 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -59,6 +59,7 @@ "copy-webpack-plugin": "^11.0.0", "css-loader": "6.8.1", "deepmerge": "^4.2.2", + "emoji-mart": "^5.5.2", "file-loader": "^6.2.0", "filesize": "^10.1.0", "focus-trap": "7.2.0", diff --git a/packages/design-system/src/assets/icons/emoji-sticker-fill.svg b/packages/design-system/src/assets/icons/emoji-sticker-fill.svg new file mode 100644 index 00000000000..c1e8623e5c6 --- /dev/null +++ b/packages/design-system/src/assets/icons/emoji-sticker-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/design-system/src/assets/icons/emoji-sticker-line.svg b/packages/design-system/src/assets/icons/emoji-sticker-line.svg new file mode 100644 index 00000000000..cf8d7aa86fb --- /dev/null +++ b/packages/design-system/src/assets/icons/emoji-sticker-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/design-system/src/components/OcEmojiPicker/OcEmojiPicker.vue b/packages/design-system/src/components/OcEmojiPicker/OcEmojiPicker.vue new file mode 100644 index 00000000000..f20be96ccc6 --- /dev/null +++ b/packages/design-system/src/components/OcEmojiPicker/OcEmojiPicker.vue @@ -0,0 +1,84 @@ + + + diff --git a/packages/design-system/src/components/index.ts b/packages/design-system/src/components/index.ts index 205122dde02..fb2b91e0ec1 100644 --- a/packages/design-system/src/components/index.ts +++ b/packages/design-system/src/components/index.ts @@ -49,3 +49,4 @@ export { default as OcTag } from './OcTag/OcTag.vue' export { default as OcTextarea } from './OcTextarea/OcTextarea.vue' export { default as OcTextInput } from './OcTextInput/OcTextInput.vue' export { default as OcErrorLog } from './OcErrorLog/OcErrorLog.vue' +export { default as OcEmojiPicker } from './OcEmojiPicker/OcEmojiPicker.vue' diff --git a/packages/web-app-files/src/components/SideBar/Actions/SpaceActions.vue b/packages/web-app-files/src/components/SideBar/Actions/SpaceActions.vue index 7a7c8fb55ec..7c4bb012478 100644 --- a/packages/web-app-files/src/components/SideBar/Actions/SpaceActions.vue +++ b/packages/web-app-files/src/components/SideBar/Actions/SpaceActions.vue @@ -24,7 +24,12 @@ diff --git a/packages/web-pkg/src/components/Modals/index.ts b/packages/web-pkg/src/components/Modals/index.ts index f63c0388f67..a658ada401b 100644 --- a/packages/web-pkg/src/components/Modals/index.ts +++ b/packages/web-pkg/src/components/Modals/index.ts @@ -1,2 +1,3 @@ export { default as ResourceConflictModal } from './ResourceConflictModal.vue' export { default as SpaceMoveInfoModal } from './SpaceMoveInfoModal.vue' +export { default as EmojiPickerModal } from './EmojiPickerModal.vue' diff --git a/packages/web-pkg/src/composables/actions/spaces/index.ts b/packages/web-pkg/src/composables/actions/spaces/index.ts index 4eaba5747bf..891a4f6ef27 100644 --- a/packages/web-pkg/src/composables/actions/spaces/index.ts +++ b/packages/web-pkg/src/composables/actions/spaces/index.ts @@ -8,3 +8,4 @@ export * from './useSpaceActionsRename' export * from './useSpaceActionsRestore' export * from './useSpaceActionsShowMembers' export * from './useSpaceActionsNavigateToTrash' +export * from './useSpaceActionsSetIcon' diff --git a/packages/web-pkg/src/composables/actions/spaces/useSpaceActionsSetIcon.ts b/packages/web-pkg/src/composables/actions/spaces/useSpaceActionsSetIcon.ts new file mode 100644 index 00000000000..322b2248df3 --- /dev/null +++ b/packages/web-pkg/src/composables/actions/spaces/useSpaceActionsSetIcon.ts @@ -0,0 +1,142 @@ +import { SpaceResource } from '@ownclouders/web-client' +import { computed } from 'vue' +import { SpaceAction, SpaceActionOptions } from '../types' +import { useClientService } from '../../clientService' +import { useLoadingService } from '../../loadingService' +import { useGettext } from 'vue3-gettext' +import { useMessages, useModals, useSpacesStore, useUserStore } from '../../piniaStores' +import { useCreateSpace } from '../../spaces' +import { buildSpace } from '@ownclouders/web-client/src/helpers' +import { eventBus } from '../../../services' +import { Drive } from '@ownclouders/web-client/src/generated' +import { blobToArrayBuffer, canvasToBlob } from '../../../helpers' +import EmojiPickerModal from '../../../components/Modals/EmojiPickerModal.vue' + +export const useSpaceActionsSetIcon = () => { + const userStore = useUserStore() + const { showMessage, showErrorMessage } = useMessages() + const { $gettext } = useGettext() + const clientService = useClientService() + const loadingService = useLoadingService() + const { createDefaultMetaFolder } = useCreateSpace() + const spacesStore = useSpacesStore() + const { dispatchModal } = useModals() + const handler = ({ resources }: SpaceActionOptions) => { + if (resources.length !== 1) { + return + } + + dispatchModal({ + title: $gettext('Set icon for %{space}', { space: resources[0].name }), + hideConfirmButton: true, + customComponent: EmojiPickerModal, + onConfirm: (emoji: string) => setIconSpace(resources[0], emoji) + }) + } + + const generateEmojiImage = async (emoji: string): Promise => { + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + const aspectRatio = 16 / 9, + width = 720, + height = width / aspectRatio + + canvas.width = width + canvas.height = height + + const textSize = 0.4 * width + context.font = `${textSize}px sans-serif` + + context.textBaseline = 'middle' + context.textAlign = 'center' + + context.fillText(emoji, canvas.width / 2, canvas.height / 2) + + const blob = await canvasToBlob(canvas) + return blobToArrayBuffer(blob) + } + + const setIconSpace = async (space: SpaceResource, emoji: string) => { + const graphClient = clientService.graphAuthenticated + const content = await generateEmojiImage(emoji) + + try { + await clientService.webdav.getFileInfo(space, { path: '.space' }) + } catch (_) { + spacesStore.updateSpaceField({ + id: space.id, + field: 'spaceReadmeData', + value: (await createDefaultMetaFolder(space)).spaceReadmeData + }) + } + + return loadingService.addTask(async () => { + const headers = { + 'Content-Type': 'application/offset+octet-stream' + } + + try { + const { fileId } = await clientService.webdav.putFileContents(space, { + path: `/.space/emoji.png`, + content, + headers, + overwrite: true + }) + + const { data } = await graphClient.drives.updateDrive( + space.id.toString(), + { + special: [ + { + specialFolder: { + name: 'image' + }, + id: fileId + } + ] + } as Drive, + {} + ) + + spacesStore.updateSpaceField({ + id: space.id.toString(), + field: 'spaceImageData', + value: data.special.find((special) => special.specialFolder.name === 'image') + }) + showMessage({ title: $gettext('Space icon was set successfully') }) + eventBus.publish('app.files.spaces.uploaded-image', buildSpace(data)) + } catch (error) { + console.error(error) + showErrorMessage({ + title: $gettext('Failed to set space icon'), + errors: [error] + }) + } + }) + } + + const actions = computed((): SpaceAction[] => [ + { + name: 'set-space-icon', + icon: 'emoji-sticker', + handler, + label: () => { + return $gettext('Set icon') + }, + isVisible: ({ resources }) => { + if (resources.length !== 1) { + return false + } + + return resources[0].canEditImage({ user: userStore.user }) + }, + componentType: 'button', + class: 'oc-files-actions-set-space-icon-trigger' + } + ]) + + return { + actions, + setIconSpace + } +} diff --git a/packages/web-pkg/src/helpers/binary.ts b/packages/web-pkg/src/helpers/binary.ts new file mode 100644 index 00000000000..5e1886cce01 --- /dev/null +++ b/packages/web-pkg/src/helpers/binary.ts @@ -0,0 +1,12 @@ +export const blobToArrayBuffer: (blob: Blob) => Promise = (blob: Blob) => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onloadend = () => resolve(reader.result) + reader.onerror = (e) => reject(e) + reader.readAsArrayBuffer(blob) + }) +} + +export const canvasToBlob = (canvas: HTMLCanvasElement): Promise => { + return new Promise((resolve) => canvas.toBlob(resolve)) +} diff --git a/packages/web-pkg/src/helpers/index.ts b/packages/web-pkg/src/helpers/index.ts index 61d14dbe83e..6a316b59a7d 100644 --- a/packages/web-pkg/src/helpers/index.ts +++ b/packages/web-pkg/src/helpers/index.ts @@ -16,3 +16,4 @@ export * from './locale' export * from './path' export * from './statusIndicators' export * from './store' +export * from './binary' diff --git a/packages/web-pkg/tests/unit/composables/actions/spaces/useSpaceActionsSetIcon.spec.ts b/packages/web-pkg/tests/unit/composables/actions/spaces/useSpaceActionsSetIcon.spec.ts new file mode 100644 index 00000000000..9f8b7cd42c8 --- /dev/null +++ b/packages/web-pkg/tests/unit/composables/actions/spaces/useSpaceActionsSetIcon.spec.ts @@ -0,0 +1,147 @@ +import { useSpaceActionsSetIcon } from '../../../../../src/composables/actions/spaces/useSpaceActionsSetIcon' +import { useMessages, useModals } from '../../../../../src/composables/piniaStores' +import { + defaultComponentMocks, + mockAxiosResolve, + RouteLocation, + getComposableWrapper +} from 'web-test-helpers' +import { unref } from 'vue' +import { SpaceResource } from '@ownclouders/web-client/src' +import { mock } from 'vitest-mock-extended' + +describe('setIcon', () => { + beforeEach(() => { + const createElementMock = vi.spyOn(document, 'createElement') + createElementMock.mockImplementation(() => { + return { + insertBefore: vi.fn(), + toBlob: () => new Blob(), + getContext: () => ({ + fillText: vi.fn() + }) + } as any + }) + }) + describe('isVisible property', () => { + it('should be false when no resource given', () => { + getWrapper({ + setup: ({ actions }) => { + expect(unref(actions)[0].isVisible({ resources: [] })).toBe(false) + } + }) + }) + it('should be false when multiple resources are given', () => { + getWrapper({ + setup: ({ actions }) => { + expect( + unref(actions)[0].isVisible({ + resources: [mock(), mock()] + }) + ).toBe(false) + } + }) + }) + it('should be false when permission is not granted', () => { + getWrapper({ + setup: ({ actions }) => { + expect( + unref(actions)[0].isVisible({ + resources: [mock({ canEditImage: () => false })] + }) + ).toBe(false) + } + }) + }) + it('should be true when permission is granted', () => { + getWrapper({ + setup: ({ actions }) => { + expect( + unref(actions)[0].isVisible({ + resources: [mock({ canEditImage: () => true })] + }) + ).toBe(true) + } + }) + }) + }) + describe('handler', () => { + it('should trigger the setIcon modal window with one resource', () => { + getWrapper({ + setup: async ({ actions }) => { + const { dispatchModal } = useModals() + await unref(actions)[0].handler({ resources: [{ id: '1' } as SpaceResource] }) + + expect(dispatchModal).toHaveBeenCalledTimes(1) + } + }) + }) + it('should not trigger the setIcon modal window with no resource', () => { + getWrapper({ + setup: async ({ actions }) => { + const { dispatchModal } = useModals() + await unref(actions)[0].handler({ resources: [] }) + + expect(dispatchModal).toHaveBeenCalledTimes(0) + } + }) + }) + }) + describe('method "setIconSpace"', () => { + it('should show message on success', () => { + getWrapper({ + setup: async ({ setIconSpace }, { clientService }) => { + clientService.graphAuthenticated.drives.updateDrive.mockResolvedValue(mockAxiosResolve()) + await setIconSpace(mock(), '🐻') + + const { showMessage } = useMessages() + expect(showMessage).toHaveBeenCalledTimes(1) + } + }) + }) + + it('should show message on error', () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined) + getWrapper({ + setup: async ({ setIconSpace }, { clientService }) => { + clientService.graphAuthenticated.drives.updateDrive.mockRejectedValue(new Error()) + await setIconSpace(mock(), '🐻') + + const { showErrorMessage } = useMessages() + expect(showErrorMessage).toHaveBeenCalledTimes(1) + } + }) + }) + }) +}) + +function getWrapper({ + setup +}: { + setup: ( + instance: ReturnType, + { + clientService + }: { + clientService: ReturnType['$clientService'] + } + ) => void +}) { + const mocks = defaultComponentMocks({ + currentRoute: mock({ name: 'files-spaces-generic' }) + }) + + return { + mocks, + wrapper: getComposableWrapper( + () => { + const instance = useSpaceActionsSetIcon() + setup(instance, { clientService: mocks.$clientService }) + }, + { + mocks, + provide: mocks + } + ) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18aff0d5108..dcf0a8bb872 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -312,6 +312,9 @@ importers: deepmerge: specifier: ^4.2.2 version: 4.3.1 + emoji-mart: + specifier: ^5.5.2 + version: 5.5.2 file-loader: specifier: ^6.2.0 version: 6.2.0(webpack@5.89.0) @@ -7678,6 +7681,10 @@ packages: engines: {node: '>4.0'} dev: false + /emoji-mart@5.5.2: + resolution: {integrity: sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==} + dev: true + /emoji-regex@10.2.1: resolution: {integrity: sha512-97g6QgOk8zlDRdgq1WxwgTMgEWGVAQvB5Fdpgc1MkNy56la5SKP9GsMXKDOdqwn90/41a8yPwIGk1Y6WVbeMQA==} dev: false