From a9d0c7fbd765dc399230310a5f290c67c4e7631b Mon Sep 17 00:00:00 2001 From: Jan Ackermann Date: Tue, 5 Mar 2024 17:26:53 +0100 Subject: [PATCH 1/6] SPACE EMOJIS --- packages/design-system/package.json | 1 + .../src/assets/icons/emoji-sticker-fill.svg | 1 + .../src/assets/icons/emoji-sticker-line.svg | 1 + .../OcEmojiPicker/OcEmojiPicker.vue | 84 ++++++++++ .../design-system/src/components/index.ts | 1 + .../SideBar/Actions/SpaceActions.vue | 9 +- .../components/Spaces/SpaceContextActions.vue | 7 +- packages/web-client/src/webdav/client/dav.ts | 4 +- .../web-client/src/webdav/putFileContents.ts | 2 +- .../components/Modals/EmojiPickerModal.vue | 40 +++++ .../web-pkg/src/components/Modals/index.ts | 1 + .../src/composables/actions/spaces/index.ts | 1 + .../actions/spaces/useSpaceActionsSetIcon.ts | 142 +++++++++++++++++ packages/web-pkg/src/helpers/binary.ts | 12 ++ packages/web-pkg/src/helpers/index.ts | 1 + .../spaces/useSpaceActionsSetIcon.spec.ts | 147 ++++++++++++++++++ pnpm-lock.yaml | 7 + 17 files changed, 455 insertions(+), 6 deletions(-) create mode 100644 packages/design-system/src/assets/icons/emoji-sticker-fill.svg create mode 100644 packages/design-system/src/assets/icons/emoji-sticker-line.svg create mode 100644 packages/design-system/src/components/OcEmojiPicker/OcEmojiPicker.vue create mode 100644 packages/web-pkg/src/components/Modals/EmojiPickerModal.vue create mode 100644 packages/web-pkg/src/composables/actions/spaces/useSpaceActionsSetIcon.ts create mode 100644 packages/web-pkg/src/helpers/binary.ts create mode 100644 packages/web-pkg/tests/unit/composables/actions/spaces/useSpaceActionsSetIcon.spec.ts 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 From 689f037c4c075c55655fc66bf724a7d05426db6d Mon Sep 17 00:00:00 2001 From: Jan Ackermann Date: Wed, 6 Mar 2024 15:07:10 +0100 Subject: [PATCH 2/6] Add ODS docs --- .../OcEmojiPicker/OcEmojiPicker.vue | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/design-system/src/components/OcEmojiPicker/OcEmojiPicker.vue b/packages/design-system/src/components/OcEmojiPicker/OcEmojiPicker.vue index f20be96ccc6..b39fdb7ff14 100644 --- a/packages/design-system/src/components/OcEmojiPicker/OcEmojiPicker.vue +++ b/packages/design-system/src/components/OcEmojiPicker/OcEmojiPicker.vue @@ -82,3 +82,23 @@ export default defineComponent({ } }) + + +```js + + +``` + From 8c21d88dd71e37797b5a849853162d69d455ad07 Mon Sep 17 00:00:00 2001 From: Jan Ackermann Date: Wed, 6 Mar 2024 15:10:33 +0100 Subject: [PATCH 3/6] Add changelog item --- changelog/unreleased/enhancement-set-emoji-as-space-icon | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog/unreleased/enhancement-set-emoji-as-space-icon diff --git a/changelog/unreleased/enhancement-set-emoji-as-space-icon b/changelog/unreleased/enhancement-set-emoji-as-space-icon new file mode 100644 index 00000000000..111dcf24787 --- /dev/null +++ b/changelog/unreleased/enhancement-set-emoji-as-space-icon @@ -0,0 +1,3 @@ +Enhancement: Set emoji as space icon + +We've added a new feature to set emojis as space icon \ No newline at end of file From 326268959ec60a5e0de33eaab4072d3310218fd5 Mon Sep 17 00:00:00 2001 From: Jan Ackermann Date: Wed, 6 Mar 2024 15:20:27 +0100 Subject: [PATCH 4/6] Add changelog item --- changelog/unreleased/enhancement-set-emoji-as-space-icon | 7 ++++++- .../composables/actions/spaces/useSpaceActionsSetIcon.ts | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/changelog/unreleased/enhancement-set-emoji-as-space-icon b/changelog/unreleased/enhancement-set-emoji-as-space-icon index 111dcf24787..1607644d5bf 100644 --- a/changelog/unreleased/enhancement-set-emoji-as-space-icon +++ b/changelog/unreleased/enhancement-set-emoji-as-space-icon @@ -1,3 +1,8 @@ Enhancement: Set emoji as space icon -We've added a new feature to set emojis as space icon \ No newline at end of file +We've added a new feature to set emojis as space icon, to do so, +the user needs to click on the 'Set icon' button in the context menu of the respective space +and has to select an emoji from the emoji picker. + +https://github.com/owncloud/web/pull/10546 +https://github.com/owncloud/web/issues/10471 diff --git a/packages/web-pkg/src/composables/actions/spaces/useSpaceActionsSetIcon.ts b/packages/web-pkg/src/composables/actions/spaces/useSpaceActionsSetIcon.ts index 322b2248df3..38fd2c96771 100644 --- a/packages/web-pkg/src/composables/actions/spaces/useSpaceActionsSetIcon.ts +++ b/packages/web-pkg/src/composables/actions/spaces/useSpaceActionsSetIcon.ts @@ -30,6 +30,7 @@ export const useSpaceActionsSetIcon = () => { title: $gettext('Set icon for %{space}', { space: resources[0].name }), hideConfirmButton: true, customComponent: EmojiPickerModal, + focusTrapInitial: 'em-emoji-picker', onConfirm: (emoji: string) => setIconSpace(resources[0], emoji) }) } From 79b7a88ca33dafa94d2917926ba0ae04ff995e2f Mon Sep 17 00:00:00 2001 From: Jan Ackermann Date: Thu, 7 Mar 2024 11:47:25 +0100 Subject: [PATCH 5/6] Add props for modal id an class --- .../src/components/OcModal/OcModal.vue | 19 ++++++++++++++++++- .../components/Modals/EmojiPickerModal.vue | 7 +------ .../actions/spaces/useSpaceActionsSetIcon.ts | 1 + .../src/composables/piniaStores/modals.ts | 2 ++ .../src/components/ModalWrapper.vue | 2 ++ 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/design-system/src/components/OcModal/OcModal.vue b/packages/design-system/src/components/OcModal/OcModal.vue index 98e81417e5f..df034ab962e 100644 --- a/packages/design-system/src/components/OcModal/OcModal.vue +++ b/packages/design-system/src/components/OcModal/OcModal.vue @@ -2,6 +2,7 @@