diff --git a/packages/web-client/src/sse/index.ts b/packages/web-client/src/sse/index.ts index 27ef142853..2d72f38c65 100644 --- a/packages/web-client/src/sse/index.ts +++ b/packages/web-client/src/sse/index.ts @@ -10,6 +10,8 @@ export enum MESSAGE_TYPE { ITEM_TRASHED = 'item-trashed', ITEM_RESTORED = 'item-restored', ITEM_MOVED = 'item-moved', + ITEM_FAVORITE_ADDED = 'item-favorite-added', + ITEM_FAVORITE_REMOVED = 'item-favorite-removed', FOLDER_CREATED = 'folder-created', SPACE_CREATED = 'space-created', SPACE_DISABLED = 'space-disabled', diff --git a/packages/web-runtime/src/container/bootstrap.ts b/packages/web-runtime/src/container/bootstrap.ts index 072fd52600..6eccce1695 100644 --- a/packages/web-runtime/src/container/bootstrap.ts +++ b/packages/web-runtime/src/container/bootstrap.ts @@ -70,6 +70,8 @@ import { onSSEFolderCreatedEvent, onSSEFileTouchedEvent, onSSEItemMovedEvent, + onSSEItemFavoriteAddedEvent, + onSSEItemFavoriteRemovedEvent, onSSESpaceMemberAddedEvent, onSSESpaceMemberRemovedEvent, onSSESpaceShareUpdatedEvent, @@ -964,6 +966,24 @@ export const registerSSEEventListeners = ({ }) ) + clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.ITEM_FAVORITE_ADDED, (msg) => + sseEventWrapper({ + topic: MESSAGE_TYPE.ITEM_FAVORITE_ADDED, + msg, + ...sseEventWrapperOptions, + method: onSSEItemFavoriteAddedEvent + }) + ) + + clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.ITEM_FAVORITE_REMOVED, (msg) => + sseEventWrapper({ + topic: MESSAGE_TYPE.ITEM_FAVORITE_REMOVED, + msg, + ...sseEventWrapperOptions, + method: onSSEItemFavoriteRemovedEvent + }) + ) + clientService.sseAuthenticated.addEventListener(MESSAGE_TYPE.FOLDER_CREATED, (msg) => sseEventWrapper({ topic: MESSAGE_TYPE.FOLDER_CREATED, diff --git a/packages/web-runtime/src/container/sse/files.ts b/packages/web-runtime/src/container/sse/files.ts index 0f2386c557..5c816ed18d 100644 --- a/packages/web-runtime/src/container/sse/files.ts +++ b/packages/web-runtime/src/container/sse/files.ts @@ -1,7 +1,8 @@ import { createFileRouteOptions, ImageDimension, - isItemInCurrentFolder + isItemInCurrentFolder, + isLocationCommonActive } from '@opencloud-eu/web-pkg' import { SSEEventOptions } from './types' @@ -312,3 +313,65 @@ export const onSSEFolderCreatedEvent = async ({ resourcesStore.upsertResource(resource) } + +/** + * Favorites are user specific, hence the favorite state of the resources currently + * loaded only ever needs to be updated for the user receiving the event. While on the + * favorites view the resource additionally needs to be added to or removed from the list. + */ +async function applyFavoriteState( + { sseData, resourcesStore, spacesStore, userStore, clientService, router }: SSEEventOptions, + starred: boolean +) { + if (sseData.initiatorid === clientService.initiatorId) { + // If initiated by current client (browser tab), action unnecessary. Web manages its own logic, return early. + return + } + + if (sseData.affecteduserids?.length && !sseData.affecteduserids.includes(userStore.user?.id)) { + return + } + + if (isLocationCommonActive(router, 'files-common-favorites')) { + if (!starred) { + const resource = resourcesStore.resources.find((r) => r.id === sseData.itemid) + + if (!resource) { + return + } + + return resourcesStore.removeResources([resource]) + } + + const space = spacesStore.spaces.find((space) => space.id === sseData.spaceid) + if (!space) { + return + } + + const resource = await clientService.webdav.getFileInfo(space, { + path: '', + fileId: sseData.itemid + }) + + if (!resource) { + return + } + + return resourcesStore.upsertResource(resource) + } + + const currentFolder = resourcesStore.currentFolder + if (currentFolder?.id === sseData.itemid) { + resourcesStore.setCurrentFolder({ ...currentFolder, starred }) + } + + resourcesStore.updateResourceField({ id: sseData.itemid, field: 'starred', value: starred }) +} + +export const onSSEItemFavoriteAddedEvent = (options: SSEEventOptions) => { + return applyFavoriteState(options, true) +} + +export const onSSEItemFavoriteRemovedEvent = (options: SSEEventOptions) => { + return applyFavoriteState(options, false) +} diff --git a/packages/web-runtime/tests/unit/container/sse/files.spec.ts b/packages/web-runtime/tests/unit/container/sse/files.spec.ts index 225964a7ec..7fd402056d 100644 --- a/packages/web-runtime/tests/unit/container/sse/files.spec.ts +++ b/packages/web-runtime/tests/unit/container/sse/files.spec.ts @@ -14,15 +14,18 @@ import { onSSEFileLockingEvent, onSSEFileTouchedEvent, onSSEFolderCreatedEvent, + onSSEItemFavoriteAddedEvent, + onSSEItemFavoriteRemovedEvent, onSSEItemMovedEvent, onSSEItemRenamedEvent, onSSEItemRestoredEvent, onSSEItemTrashedEvent } from '../../../../src/container/sse' -import { Router } from 'vue-router' +import { RouteLocation } from 'vue-router' import { mock, mockDeep } from 'vitest-mock-extended' import { Resource, SpaceResource } from '@opencloud-eu/web-client' -import { createTestingPinia } from '@opencloud-eu/web-test-helpers' +import { User } from '@opencloud-eu/web-client/graph/generated' +import { createTestingPinia, defaultComponentMocks } from '@opencloud-eu/web-test-helpers' import { Language } from 'vue3-gettext' import PQueue from 'p-queue' @@ -423,6 +426,117 @@ describe('file events', () => { expect(mocks.resourcesStore.upsertResource).not.toHaveBeenCalled() }) }) + + describe('onSSEItemFavoriteAddedEvent', () => { + it('calls "updateResourceField" when resource has been marked as favorite', async () => { + const favoritedResource = mock({ id: 'file1', storageId: 'space1' }) + const mocks = getMocks({ resources: [favoritedResource] }) + const sseData = mock({ + itemid: favoritedResource.id, + spaceid: favoritedResource.storageId, + affecteduserids: ['1'] + }) + await onSSEItemFavoriteAddedEvent({ sseData, ...mocks }) + expect(mocks.resourcesStore.updateResourceField).toHaveBeenCalledWith({ + id: favoritedResource.id, + field: 'starred', + value: true + }) + }) + it('calls "setCurrentFolder" when the current folder has been marked as favorite', async () => { + const mocks = getMocks() + const sseData = mock({ + itemid: mocks.resourcesStore.currentFolder.id, + spaceid: 'space1' + }) + await onSSEItemFavoriteAddedEvent({ sseData, ...mocks }) + expect(mocks.resourcesStore.setCurrentFolder).toHaveBeenCalledWith( + expect.objectContaining({ id: mocks.resourcesStore.currentFolder.id, starred: true }) + ) + }) + it('calls "upsertResource" when being on the favorites view', async () => { + const favoritedResource = mock({ id: 'file1', storageId: 'space1' }) + const mocks = getMocks({ currentRouteName: 'files-common-favorites' }) + mocks.clientService.webdav.getFileInfo.mockResolvedValue(favoritedResource) + const sseData = mock({ + itemid: favoritedResource.id, + spaceid: favoritedResource.storageId + }) + await onSSEItemFavoriteAddedEvent({ sseData, ...mocks }) + expect(mocks.clientService.webdav.getFileInfo).toHaveBeenCalled() + expect(mocks.resourcesStore.upsertResource).toHaveBeenCalledWith(favoritedResource) + }) + it('does not trigger any action when the current user is not affected', async () => { + const favoritedResource = mock({ id: 'file1', storageId: 'space1' }) + const mocks = getMocks({ resources: [favoritedResource] }) + const sseData = mock({ + itemid: favoritedResource.id, + spaceid: favoritedResource.storageId, + affecteduserids: ['2'] + }) + await onSSEItemFavoriteAddedEvent({ sseData, ...mocks }) + expect(mocks.resourcesStore.updateResourceField).not.toHaveBeenCalled() + }) + it('does not trigger any action when initiator ids are identical', async () => { + const favoritedResource = mock({ id: 'file1', storageId: 'space1' }) + const mocks = getMocks({ resources: [favoritedResource] }) + const sseData = mock({ + itemid: favoritedResource.id, + spaceid: favoritedResource.storageId, + initiatorid: 'local1' + }) + await onSSEItemFavoriteAddedEvent({ sseData, ...mocks }) + expect(mocks.resourcesStore.updateResourceField).not.toHaveBeenCalled() + }) + }) + + describe('onSSEItemFavoriteRemovedEvent', () => { + it('calls "updateResourceField" when resource has been unmarked as favorite', async () => { + const favoritedResource = mock({ id: 'file1', storageId: 'space1' }) + const mocks = getMocks({ resources: [favoritedResource] }) + const sseData = mock({ + itemid: favoritedResource.id, + spaceid: favoritedResource.storageId + }) + await onSSEItemFavoriteRemovedEvent({ sseData, ...mocks }) + expect(mocks.resourcesStore.updateResourceField).toHaveBeenCalledWith({ + id: favoritedResource.id, + field: 'starred', + value: false + }) + }) + it('calls "removeResources" when being on the favorites view', async () => { + const favoritedResource = mock({ id: 'file1', storageId: 'space1' }) + const mocks = getMocks({ + resources: [favoritedResource], + currentRouteName: 'files-common-favorites' + }) + const sseData = mock({ + itemid: favoritedResource.id, + spaceid: favoritedResource.storageId + }) + await onSSEItemFavoriteRemovedEvent({ sseData, ...mocks }) + expect(mocks.resourcesStore.removeResources).toHaveBeenCalledWith([favoritedResource]) + expect(mocks.clientService.webdav.getFileInfo).not.toHaveBeenCalled() + }) + it('does not trigger any action when the resource is not loaded on the favorites view', async () => { + const mocks = getMocks({ currentRouteName: 'files-common-favorites' }) + const sseData = mock({ itemid: 'file1', spaceid: 'space1' }) + await onSSEItemFavoriteRemovedEvent({ sseData, ...mocks }) + expect(mocks.resourcesStore.removeResources).not.toHaveBeenCalled() + }) + it('does not trigger any action when initiator ids are identical', async () => { + const favoritedResource = mock({ id: 'file1', storageId: 'space1' }) + const mocks = getMocks({ resources: [favoritedResource] }) + const sseData = mock({ + itemid: favoritedResource.id, + spaceid: favoritedResource.storageId, + initiatorid: 'local1' + }) + await onSSEItemFavoriteRemovedEvent({ sseData, ...mocks }) + expect(mocks.resourcesStore.updateResourceField).not.toHaveBeenCalled() + }) + }) }) const getMocks = ({ currentFolder = mockDeep({ @@ -431,8 +545,14 @@ const getMocks = ({ storageId: 'space1' }), resources = [], - spaces = [mockDeep({ id: 'space1' })] -}: { currentFolder?: Resource; resources?: Resource[]; spaces?: SpaceResource[] } = {}) => { + spaces = [mockDeep({ id: 'space1' })], + currentRouteName = 'files-spaces-generic' +}: { + currentFolder?: Resource + resources?: Resource[] + spaces?: SpaceResource[] + currentRouteName?: string +} = {}) => { createTestingPinia() const resourcesStore = useResourcesStore() resourcesStore.currentFolder = currentFolder @@ -441,12 +561,15 @@ const getMocks = ({ spacesStore.spaces = spaces const messageStore = useMessages() const userStore = useUserStore() + userStore.user = mockDeep({ id: '1' }) const sharesStore = useSharesStore() const configStore = useConfigStore() const authStore = useAuthStore() const clientService = mockDeep({ initiatorId: 'local1' }) const previewService = mockDeep() - const router = mockDeep() + const { $router: router } = defaultComponentMocks({ + currentRoute: mock({ name: currentRouteName }) + }) const language = mockDeep({ $gettext: vi.fn((m) => m) })