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