From c16c0171aa057cb5e07424aef8da36589cf18120 Mon Sep 17 00:00:00 2001 From: Jannik Stehle Date: Tue, 26 May 2026 15:41:48 +0200 Subject: [PATCH 1/2] feat: implement user mentions in Collabora docs Users can be mentioned with `@` in Collabora documents, triggering a notification to the mentioned user. Also refactors all Collabora postMessage handlers to a dedicated composable. --- packages/web-app-external/src/App.vue | 198 +---- .../web-app-external/src/composables/index.ts | 1 + .../composables/useCollaboraPostMessages.ts | 387 ++++++++++ .../useCollaboraPostMessages.spec.ts | 680 ++++++++++++++++++ .../src/components/SideBar/FileSideBar.vue | 190 +---- .../web-pkg/src/composables/shares/index.ts | 1 + .../src/composables/shares/useLoadShares.ts | 226 ++++++ 7 files changed, 1312 insertions(+), 371 deletions(-) create mode 100644 packages/web-app-external/src/composables/useCollaboraPostMessages.ts create mode 100644 packages/web-app-external/tests/unit/composables/useCollaboraPostMessages.spec.ts create mode 100644 packages/web-pkg/src/composables/shares/useLoadShares.ts diff --git a/packages/web-app-external/src/App.vue b/packages/web-app-external/src/App.vue index 5026ba5fa8..b94c340f02 100644 --- a/packages/web-app-external/src/App.vue +++ b/packages/web-app-external/src/App.vue @@ -43,6 +43,7 @@ import { unref, nextTick, ref, + toRef, watch, onMounted, useTemplateRef, @@ -74,15 +75,10 @@ import { useSpacesStore, useClientService, useSharesStore, - useModals, - useRouter, - useThemeStore, - useFolderLink, - FilePickerModal + useThemeStore } from '@opencloud-eu/web-pkg' -import FileNameModal from './components/FileNameModal.vue' -import { DavProperty } from '@opencloud-eu/web-client/webdav' import { storeToRefs } from 'pinia' +import { useCollaboraPostMessages } from './composables' const { space, resource, isReadOnly } = defineProps<{ space: SpaceResource @@ -98,16 +94,13 @@ const { showErrorMessage } = useMessages() const capabilityStore = useCapabilityStore() const configStore = useConfigStore() const route = useRoute() -const router = useRouter() const appProviderService = useAppProviderService() const { makeRequest } = useRequest() const spacesStore = useSpacesStore() const sharesStore = useSharesStore() -const { graphAuthenticated: graphClient, webdav } = useClientService() -const { dispatchModal } = useModals() +const { graphAuthenticated: graphClient } = useClientService() const themeStore = useThemeStore() const { currentTheme } = storeToRefs(themeStore) -const { getParentFolderLink } = useFolderLink() const viewModeQuery = useRouteQuery('view_mode') const viewModeQueryValue = computed(() => { @@ -261,166 +254,13 @@ const catchClickMicrosoftEdit = (event: MessageEvent) => { } catch {} } -const handlePostMessagesCollabora = async (event: MessageEvent) => { - try { - const message = JSON.parse(event.data || '{}') - - if (message.MessageId === 'App_LoadingStatus') { - postMessageToCollabora('Hide_Button', { id: 'toggledarktheme' }) - - if (message.Values?.Status === 'Frame_Ready') { - postMessageToCollabora('Host_PostmessageReady') - } - return - } - - if (message.MessageId === 'UI_SaveAs') { - if (Object.hasOwn(message.Values, 'format')) { - dispatchModal({ - title: $gettext('Export »%{name}« as %{format}', { - name: resource.name, - format: message.Values.format - }), - customComponent: markRaw(FileNameModal), - customComponentAttrs: () => ({ - space, - resource, - fileExtension: message.Values.format, - callbackFn: (newFileName: string) => { - postMessageToCollabora('Action_SaveAs', { - Filename: newFileName, - Notify: true - }) - } - }) - }) - return - } - - dispatchModal({ - title: $gettext('Save »%{name}« with new name', { name: resource.name }), - customComponent: markRaw(FileNameModal), - customComponentAttrs: () => ({ - space, - resource, - callbackFn: (newFileName: string) => { - postMessageToCollabora('Action_SaveAs', { - Filename: newFileName, - Notify: true - }) - } - }) - }) - return - } - - if (message.MessageId === 'Action_Save_Resp') { - if (!message.Values?.fileName) { - return - } - - // FIXME: when we move to id based propfinds we magically need a fileId for the new file. Collabora doesn't provide that. - const newFile = await webdav.getFileInfo(space, { - path: - resource.path.substring(0, resource.path.length - resource.name.length) + - message.Values.fileName, - fileId: undefined - }) - await router.push({ - name: unref(route).name, - params: { - ...unref(route).params, - driveAliasAndItem: queryItemAsString(unref(route).params.driveAliasAndItem).replace( - resource.name, - newFile.name - ) - }, - query: { - ...unref(route).query, - fileId: newFile.fileId - } - }) - return - } - - if (message.MessageId === 'UI_InsertGraphic') { - dispatchModal({ - elementClass: 'file-picker-modal', - title: $gettext('Insert graphic'), - customComponent: markRaw(FilePickerModal), - hideActions: true, - customComponentAttrs: () => ({ - parentFolderLink: getParentFolderLink(resource), - allowedFileTypes: ['image/png', 'image/gif', 'image/jpeg', 'image/svg'], - callbackFn: async ({ resource }: { resource: Resource }) => { - const { downloadURL: url } = await webdav.getFileInfo(space, resource, { - davProperties: [DavProperty.DownloadURL] - }) - - postMessageToCollabora('Action_InsertGraphic', { url }) - } - }), - focusTrapInitial: false - }) - return - } - - if (message.MessageId === 'UI_InsertFile') { - const callback = message.Values?.callback - const mimeTypeFilter = message.Values?.mimeTypeFilter - - dispatchModal({ - elementClass: 'file-picker-modal', - title: - callback === 'Action_CompareDocuments' - ? $gettext('Select document to compare') - : $gettext('Insert file'), - customComponent: markRaw(FilePickerModal), - hideActions: true, - customComponentAttrs: () => ({ - parentFolderLink: getParentFolderLink(resource), - allowedFileTypes: mimeTypeFilter || [], - callbackFn: async ({ resource }: { resource: Resource }) => { - const { downloadURL: url } = await webdav.getFileInfo(space, resource, { - davProperties: [DavProperty.DownloadURL] - }) - - const values: Record = { url } - if (callback === 'Action_CompareDocuments') { - values.filename = resource.name - } - - postMessageToCollabora(callback, values) - } - }), - focusTrapInitial: false - }) - return - } +const appIframeRef = useTemplateRef('appIframe') - if (message.MessageId === 'UI_PickLink') { - dispatchModal({ - elementClass: 'file-picker-modal', - title: $gettext('Pick a file to link'), - customComponent: markRaw(FilePickerModal), - hideActions: true, - customComponentAttrs: () => ({ - parentFolderLink: getParentFolderLink(resource), - allowedFileTypes: [], - callbackFn: ({ resource }: { resource: Resource }) => { - postMessageToCollabora('Action_InsertLink', { - url: resource.privateLink, - text: resource.name - }) - } - }), - focusTrapInitial: false - }) - } - } catch (e) { - console.debug('Error parsing Collabora PostMessage', e) - } -} +const { handlePostMessagesCollabora, resetMentionState } = useCollaboraPostMessages({ + space: toRef(() => space), + resource: toRef(() => resource), + appIframeRef +}) onMounted(() => { if (determineOpenAsPreview(unref(appName))) { @@ -440,23 +280,6 @@ onBeforeUnmount(() => { } }) -const appIframeRef = useTemplateRef('appIframe') -const postMessageToCollabora = (messageId: string, values?: { [key: string]: unknown }): void => { - if (!unref(appIframeRef)) { - console.error('Collabora iframe not found') - return - } - - return unref(appIframeRef).contentWindow.postMessage( - JSON.stringify({ - MessageId: messageId, - SendTime: Date.now(), - ...(values && { Values: values }) - }), - '*' - ) -} - watch( () => resource, async (newResource, oldResource) => { @@ -464,6 +287,7 @@ watch( return } + resetMentionState() let viewMode = 'read' if (isShareSpaceResource(space)) { diff --git a/packages/web-app-external/src/composables/index.ts b/packages/web-app-external/src/composables/index.ts index 580f0c4b23..6a22900ed6 100644 --- a/packages/web-app-external/src/composables/index.ts +++ b/packages/web-app-external/src/composables/index.ts @@ -1 +1,2 @@ export * from './createFileHandler' +export * from './useCollaboraPostMessages' diff --git a/packages/web-app-external/src/composables/useCollaboraPostMessages.ts b/packages/web-app-external/src/composables/useCollaboraPostMessages.ts new file mode 100644 index 0000000000..51366987bc --- /dev/null +++ b/packages/web-app-external/src/composables/useCollaboraPostMessages.ts @@ -0,0 +1,387 @@ +import { onMounted, onBeforeUnmount, ref, unref, type Ref } from 'vue' +import { useGettext } from 'vue3-gettext' +import { + CollaboratorShare, + Resource, + SpaceResource, + ShareTypes, + urlJoin +} from '@opencloud-eu/web-client' +import { DavProperty } from '@opencloud-eu/web-client/webdav' +import { + useRoute, + useRouter, + useClientService, + useModals, + useFolderLink, + useSharesStore, + useLoadShares, + useConfigStore, + queryItemAsString, + FilePickerModal +} from '@opencloud-eu/web-pkg' +import FileNameModal from '../components/FileNameModal.vue' + +interface CollaboraMessage { + MessageId: string + Values?: Record +} + +export function useCollaboraPostMessages({ + space, + resource, + appIframeRef +}: { + space: Ref + resource: Ref + appIframeRef: Ref +}) { + const { $gettext } = useGettext() + const route = useRoute() + const router = useRouter() + const { httpAuthenticated, webdav } = useClientService() + const { dispatchModal } = useModals() + const { getParentFolderLink } = useFolderLink() + const sharesStore = useSharesStore() + const { loadSharesTask } = useLoadShares() + const configStore = useConfigStore() + + const collaborators = ref([]) + const collaboratorsFetched = ref(false) + const userIdsToMention = ref([]) + + function postMessageToCollabora(messageId: string, values?: Record): void { + const iframe = unref(appIframeRef) + if (!iframe) { + console.error('Collabora iframe not found') + return + } + iframe.contentWindow.postMessage( + JSON.stringify({ + MessageId: messageId, + SendTime: Date.now(), + ...(values && { Values: values }) + }), + '*' + ) + } + + function handleAppLoadingStatus(message: CollaboraMessage): void { + postMessageToCollabora('Hide_Button', { id: 'toggledarktheme' }) + if (message.Values?.Status === 'Frame_Ready') { + postMessageToCollabora('Host_PostmessageReady') + } + } + + function handleDocModifiedStatus(message: CollaboraMessage): void { + if (message.Values?.Modified === false) { + notifyMentionedUsers() + } + } + + function handleUiClose(): void { + notifyMentionedUsers() + } + + function handleUiSaveAs(message: CollaboraMessage): void { + const currentResource = unref(resource) + const currentSpace = unref(space) + + if (Object.hasOwn(message.Values ?? {}, 'format')) { + dispatchModal({ + title: $gettext('Export »%{name}« as %{format}', { + name: currentResource.name, + format: message.Values.format as string + }), + customComponent: FileNameModal, + customComponentAttrs: () => ({ + space: currentSpace, + resource: currentResource, + fileExtension: message.Values.format, + callbackFn: (newFileName: string) => { + postMessageToCollabora('Action_SaveAs', { Filename: newFileName, Notify: true }) + } + }) + }) + return + } + + dispatchModal({ + title: $gettext('Save »%{name}« with new name', { name: currentResource.name }), + customComponent: FileNameModal, + customComponentAttrs: () => ({ + space: currentSpace, + resource: currentResource, + callbackFn: (newFileName: string) => { + postMessageToCollabora('Action_SaveAs', { Filename: newFileName, Notify: true }) + } + }) + }) + } + + async function handleActionSaveResp(message: CollaboraMessage): Promise { + if (!message.Values?.fileName) { + return + } + + const currentResource = unref(resource) + const currentSpace = unref(space) + + // FIXME: when we move to id based propfinds we magically need a fileId for the new file. Collabora doesn't provide that. + const newFile = await webdav.getFileInfo(currentSpace, { + path: + currentResource.path.substring( + 0, + currentResource.path.length - currentResource.name.length + ) + (message.Values.fileName as string), + fileId: undefined + }) + await router.push({ + name: unref(route).name, + params: { + ...unref(route).params, + driveAliasAndItem: queryItemAsString(unref(route).params.driveAliasAndItem).replace( + currentResource.name, + newFile.name + ) + }, + query: { + ...unref(route).query, + fileId: newFile.fileId + } + }) + } + + function handleUiInsertGraphic(): void { + const currentResource = unref(resource) + const currentSpace = unref(space) + + dispatchModal({ + elementClass: 'file-picker-modal', + title: $gettext('Insert graphic'), + customComponent: FilePickerModal, + hideActions: true, + customComponentAttrs: () => ({ + parentFolderLink: getParentFolderLink(currentResource), + allowedFileTypes: ['image/png', 'image/gif', 'image/jpeg', 'image/svg'], + callbackFn: async ({ resource: pickedResource }: { resource: Resource }) => { + const { downloadURL: url } = await webdav.getFileInfo(currentSpace, pickedResource, { + davProperties: [DavProperty.DownloadURL] + }) + postMessageToCollabora('Action_InsertGraphic', { url }) + } + }), + focusTrapInitial: false + }) + } + + function handleUiInsertFile(message: CollaboraMessage): void { + const currentResource = unref(resource) + const currentSpace = unref(space) + const callback = message.Values?.callback + const mimeTypeFilter = message.Values?.mimeTypeFilter + + if (typeof callback !== 'string') { + return + } + + dispatchModal({ + elementClass: 'file-picker-modal', + title: + callback === 'Action_CompareDocuments' + ? $gettext('Select document to compare') + : $gettext('Insert file'), + customComponent: FilePickerModal, + hideActions: true, + customComponentAttrs: () => ({ + parentFolderLink: getParentFolderLink(currentResource), + allowedFileTypes: (mimeTypeFilter as string[]) || [], + callbackFn: async ({ resource: pickedResource }: { resource: Resource }) => { + const { downloadURL: url } = await webdav.getFileInfo(currentSpace, pickedResource, { + davProperties: [DavProperty.DownloadURL] + }) + const values: Record = { url } + if (callback === 'Action_CompareDocuments') { + values.filename = pickedResource.name + } + postMessageToCollabora(callback, values) + } + }), + focusTrapInitial: false + }) + } + + function handleUiPickLink(): void { + const currentResource = unref(resource) + + dispatchModal({ + elementClass: 'file-picker-modal', + title: $gettext('Pick a file to link'), + customComponent: FilePickerModal, + hideActions: true, + customComponentAttrs: () => ({ + parentFolderLink: getParentFolderLink(currentResource), + allowedFileTypes: [], + callbackFn: ({ resource: pickedResource }: { resource: Resource }) => { + postMessageToCollabora('Action_InsertLink', { + url: pickedResource.privateLink, + text: pickedResource.name + }) + } + }), + focusTrapInitial: false + }) + } + + async function handleUiMention(message: CollaboraMessage): Promise { + if (message.Values?.type === 'autocomplete') { + await handleMentionAutocomplete((message.Values.text as string) || '') + return + } + if (message.Values?.type === 'selected' && typeof message.Values.username === 'string') { + handleMentionSelected(message.Values.username) + } + } + + async function handleMentionAutocomplete(text: string): Promise { + if (loadSharesTask.isRunning) { + loadSharesTask.cancelAll() + } + + if (!unref(collaboratorsFetched)) { + const { collaboratorShares } = await loadSharesTask.perform({ + space: unref(space), + resource: unref(resource), + updateStore: false + }) + // dedupe by user id (a user can be space member and share recipient) + collaborators.value = collaboratorShares.filter( + (share, index, self) => + index === self.findIndex((s) => s.sharedWith.id === share.sharedWith.id) + ) + collaboratorsFetched.value = true + } + + if (!unref(collaborators).length) { + return + } + + const searchText = text.toLowerCase() + const individualShareTypeValues = ShareTypes.individuals.map((t) => t.value) + + const list = unref(collaborators) + .filter( + (m) => + individualShareTypeValues.includes(m.shareType) && + m.sharedWith.id && + m.sharedWith.displayName?.toLowerCase().startsWith(searchText) + ) + .map((m) => ({ + username: m.sharedWith.id, + // Collabora expects a URL for the profile, which we don't have + // hence use the URL for the current document + profile: urlJoin(configStore.serverUrl, 'f', unref(resource).id), + label: m.sharedWith.displayName || m.sharedWith.id + })) + + postMessageToCollabora('Action_Mention', { list }) + } + + function handleMentionSelected(userId: string): void { + if (!unref(userIdsToMention).includes(userId)) { + unref(userIdsToMention).push(userId) + } + } + + async function notifyMentionedUsers(): Promise { + if (unref(userIdsToMention).length === 0) { + return + } + + try { + await httpAuthenticated.post(urlJoin(configStore.serverUrl, 'collaboration/notify'), { + fileID: unref(resource).fileId, + userIDs: unref(userIdsToMention), + type: 'mention' + }) + } catch (e) { + console.error('Error notifying mentioned users', e) + } finally { + userIdsToMention.value = [] + } + } + + function resetMentionState(): void { + if (loadSharesTask.isRunning) { + loadSharesTask.cancelAll() + } + collaborators.value = [] + collaboratorsFetched.value = false + } + + sharesStore.$onAction(({ after, name }) => { + after(() => { + // when shares are added/removed while the app is open (via right sidebar), + // we need to update the list of collaborators for mentions in Collabora + if (['addShare', 'removeShare'].includes(name)) { + collaboratorsFetched.value = false + } + }) + }) + + function handleWindowBeforeUnload(): void { + notifyMentionedUsers() + } + + onMounted(() => { + window.addEventListener('beforeunload', handleWindowBeforeUnload) + }) + + onBeforeUnmount(() => { + window.removeEventListener('beforeunload', handleWindowBeforeUnload) + notifyMentionedUsers() + }) + + async function handlePostMessagesCollabora(event: MessageEvent): Promise { + try { + const message: CollaboraMessage = JSON.parse(event.data || '{}') + + switch (message.MessageId) { + case 'App_LoadingStatus': + handleAppLoadingStatus(message) + break + case 'Doc_ModifiedStatus': + handleDocModifiedStatus(message) + break + case 'UI_Close': + handleUiClose() + break + case 'UI_SaveAs': + handleUiSaveAs(message) + break + case 'Action_Save_Resp': + await handleActionSaveResp(message) + break + case 'UI_InsertGraphic': + handleUiInsertGraphic() + break + case 'UI_InsertFile': + handleUiInsertFile(message) + break + case 'UI_Mention': + await handleUiMention(message) + break + case 'UI_PickLink': + handleUiPickLink() + break + } + } catch (e) { + console.debug('Error parsing Collabora PostMessage', e) + } + } + + return { + handlePostMessagesCollabora, + resetMentionState + } +} diff --git a/packages/web-app-external/tests/unit/composables/useCollaboraPostMessages.spec.ts b/packages/web-app-external/tests/unit/composables/useCollaboraPostMessages.spec.ts new file mode 100644 index 0000000000..2fe2f0ee5f --- /dev/null +++ b/packages/web-app-external/tests/unit/composables/useCollaboraPostMessages.spec.ts @@ -0,0 +1,680 @@ +import { ref, type Ref } from 'vue' +import { mock } from 'vitest-mock-extended' +import { flushPromises } from '@vue/test-utils' +import { + defaultComponentMocks, + getComposableWrapper, + type RouteLocation +} from '@opencloud-eu/web-test-helpers' +import { CollaboratorShare, Resource, ShareTypes, SpaceResource } from '@opencloud-eu/web-client' +import { useLoadShares, useFolderLink, useModals } from '@opencloud-eu/web-pkg' +import { useCollaboraPostMessages } from '../../../src/composables/useCollaboraPostMessages' +import { Mock } from 'vitest' + +vi.mock('@opencloud-eu/web-pkg', async (importOriginal) => ({ + ...(await importOriginal()), + useLoadShares: vi.fn(), + useFolderLink: vi.fn() +})) + +const createMessageEvent = (data: Record) => + new MessageEvent('message', { data: JSON.stringify(data) }) + +const createMockIframe = (postMessage = vi.fn()): HTMLIFrameElement => + ({ contentWindow: { postMessage } }) as unknown as HTMLIFrameElement + +const getParsedPostMessageCalls = (postMessage: ReturnType) => + postMessage.mock.calls.map( + (call) => + JSON.parse(call[0] as string) as { MessageId: string; Values?: Record } + ) + +describe('useCollaboraPostMessages', () => { + let mockLoadSharesTask: { + isRunning: boolean + cancelAll: ReturnType + perform: ReturnType + } + + beforeEach(() => { + mockLoadSharesTask = { + isRunning: false, + cancelAll: vi.fn(), + perform: vi.fn().mockResolvedValue({ collaboratorShares: [], linkShares: [] }) + } + vi.mocked(useLoadShares).mockReturnValue({ + loadSharesTask: mockLoadSharesTask as any, + availableInternalShareRoles: ref([]), + availableExternalShareRoles: ref([]) + }) + vi.mocked(useFolderLink).mockReturnValue({ + getParentFolderLink: vi.fn().mockReturnValue({}), + getFolderLink: vi.fn(), + getPathPrefix: vi.fn(), + getParentFolderName: vi.fn(), + getParentFolderLinkIconAdditionalAttributes: vi.fn() + }) + }) + + describe('postMessageToCollabora', () => { + it('logs an error when the iframe is not available', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const { instance } = getWrapper({ appIframeRef: null }) + + await instance.handlePostMessagesCollabora( + createMessageEvent({ MessageId: 'App_LoadingStatus', Values: { Status: 'Frame_Ready' } }) + ) + + expect(consoleError).toHaveBeenCalledWith('Collabora iframe not found') + }) + + it('sends the message as JSON to the iframe content window', async () => { + const postMessage = vi.fn() + const { instance } = getWrapper({ appIframeRef: ref(createMockIframe(postMessage)) }) + + await instance.handlePostMessagesCollabora( + createMessageEvent({ MessageId: 'App_LoadingStatus', Values: { Status: 'Loading' } }) + ) + + expect(postMessage).toHaveBeenCalledWith(expect.stringContaining('"MessageId"'), '*') + const [data] = postMessage.mock.calls[0] + const parsed = JSON.parse(data as string) + expect(parsed).toHaveProperty('MessageId') + expect(parsed).toHaveProperty('SendTime') + }) + }) + + describe('App_LoadingStatus message', () => { + it('always posts Hide_Button with id toggledarktheme', async () => { + const postMessage = vi.fn() + const { instance } = getWrapper({ appIframeRef: ref(createMockIframe(postMessage)) }) + + await instance.handlePostMessagesCollabora( + createMessageEvent({ MessageId: 'App_LoadingStatus', Values: { Status: 'Loading' } }) + ) + + const calls = getParsedPostMessageCalls(postMessage) + const hideButton = calls.find((m) => m.MessageId === 'Hide_Button') + expect(hideButton?.Values).toEqual({ id: 'toggledarktheme' }) + }) + + it('posts Host_PostmessageReady when Status is Frame_Ready', async () => { + const postMessage = vi.fn() + const { instance } = getWrapper({ appIframeRef: ref(createMockIframe(postMessage)) }) + + await instance.handlePostMessagesCollabora( + createMessageEvent({ MessageId: 'App_LoadingStatus', Values: { Status: 'Frame_Ready' } }) + ) + + const messageIds = getParsedPostMessageCalls(postMessage).map((m) => m.MessageId) + expect(messageIds).toContain('Host_PostmessageReady') + }) + + it('does not post Host_PostmessageReady when Status is not Frame_Ready', async () => { + const postMessage = vi.fn() + const { instance } = getWrapper({ appIframeRef: ref(createMockIframe(postMessage)) }) + + await instance.handlePostMessagesCollabora( + createMessageEvent({ MessageId: 'App_LoadingStatus', Values: { Status: 'Loading' } }) + ) + + const messageIds = getParsedPostMessageCalls(postMessage).map((m) => m.MessageId) + expect(messageIds).not.toContain('Host_PostmessageReady') + }) + }) + + describe('Doc_ModifiedStatus message', () => { + it('notifies mentioned users when Modified is false', async () => { + const { instance, mocks } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ + MessageId: 'UI_Mention', + Values: { type: 'selected', username: 'user1' } + }) + ) + await instance.handlePostMessagesCollabora( + createMessageEvent({ MessageId: 'Doc_ModifiedStatus', Values: { Modified: false } }) + ) + await flushPromises() + + expect(mocks.$clientService.httpAuthenticated.post).toHaveBeenCalledOnce() + }) + + it('does not notify when Modified is true', async () => { + const { instance, mocks } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ + MessageId: 'UI_Mention', + Values: { type: 'selected', username: 'user1' } + }) + ) + await instance.handlePostMessagesCollabora( + createMessageEvent({ MessageId: 'Doc_ModifiedStatus', Values: { Modified: true } }) + ) + await flushPromises() + + expect(mocks.$clientService.httpAuthenticated.post).not.toHaveBeenCalled() + }) + }) + + describe('UI_Close message', () => { + it('notifies mentioned users on close', async () => { + const { instance, mocks } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ + MessageId: 'UI_Mention', + Values: { type: 'selected', username: 'user1' } + }) + ) + await instance.handlePostMessagesCollabora(createMessageEvent({ MessageId: 'UI_Close' })) + await flushPromises() + + expect(mocks.$clientService.httpAuthenticated.post).toHaveBeenCalledOnce() + }) + + it('does not notify if no users were mentioned', async () => { + const { instance, mocks } = getWrapper() + + await instance.handlePostMessagesCollabora(createMessageEvent({ MessageId: 'UI_Close' })) + await flushPromises() + + expect(mocks.$clientService.httpAuthenticated.post).not.toHaveBeenCalled() + }) + }) + + describe('page leave / refresh', () => { + it('notifies mentioned users on beforeunload', async () => { + const { instance, mocks } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ + MessageId: 'UI_Mention', + Values: { type: 'selected', username: 'user1' } + }) + ) + window.dispatchEvent(new Event('beforeunload')) + await flushPromises() + + expect(mocks.$clientService.httpAuthenticated.post).toHaveBeenCalledOnce() + }) + + it('does not notify on beforeunload if no users were mentioned', async () => { + const { mocks } = getWrapper() + + window.dispatchEvent(new Event('beforeunload')) + await flushPromises() + + expect(mocks.$clientService.httpAuthenticated.post).not.toHaveBeenCalled() + }) + + it('notifies mentioned users on component unmount', async () => { + const { wrapper, instance, mocks } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ + MessageId: 'UI_Mention', + Values: { type: 'selected', username: 'user1' } + }) + ) + wrapper.unmount() + await flushPromises() + + expect(mocks.$clientService.httpAuthenticated.post).toHaveBeenCalledOnce() + }) + + it('does not fire beforeunload listener after component unmount', async () => { + const { wrapper, instance, mocks } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ + MessageId: 'UI_Mention', + Values: { type: 'selected', username: 'user1' } + }) + ) + wrapper.unmount() + await flushPromises() + + // Trigger beforeunload after unmount - listener should have been removed + window.dispatchEvent(new Event('beforeunload')) + await flushPromises() + + expect(mocks.$clientService.httpAuthenticated.post).toHaveBeenCalledOnce() + }) + }) + + describe('UI_SaveAs message', () => { + it('dispatches FileNameModal with format name in title when format is provided', async () => { + const { instance } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ MessageId: 'UI_SaveAs', Values: { format: 'docx' } }) + ) + + const { dispatchModal } = useModals() + expect(dispatchModal).toHaveBeenCalledOnce() + }) + + it('passes the format as fileExtension to the modal component attrs when format is provided', async () => { + const { instance } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ MessageId: 'UI_SaveAs', Values: { format: 'docx' } }) + ) + + const { dispatchModal } = useModals() + expect(dispatchModal).toHaveBeenCalled() + }) + + it('dispatches FileNameModal with save-as title when no format is provided', async () => { + const { instance } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ MessageId: 'UI_SaveAs', Values: {} }) + ) + + const { dispatchModal } = useModals() + expect(dispatchModal).toHaveBeenCalled() + }) + }) + + describe('Action_Save_Resp message', () => { + it('returns early when fileName is not provided', async () => { + const { instance, mocks } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ MessageId: 'Action_Save_Resp', Values: {} }) + ) + await flushPromises() + + expect(mocks.$clientService.webdav.getFileInfo).not.toHaveBeenCalled() + }) + + it('fetches the new file info and navigates to it', async () => { + const newFile = mock({ name: 'renamed.docx', fileId: 'new-file-id' }) + const mocks = defaultComponentMocks({ + currentRoute: mock({ + name: 'external', + params: { driveAliasAndItem: 'personal/original.odt' }, + query: {} + }) + }) + mocks.$clientService.webdav.getFileInfo.mockResolvedValue(newFile) + + const { instance } = getWrapper({ + mocks, + resource: ref(mock({ name: 'original.odt', path: '/folder/original.odt' })) + }) + + await instance.handlePostMessagesCollabora( + createMessageEvent({ MessageId: 'Action_Save_Resp', Values: { fileName: 'renamed.docx' } }) + ) + await flushPromises() + + expect(mocks.$clientService.webdav.getFileInfo).toHaveBeenCalledOnce() + expect(mocks.$router.push).toHaveBeenCalledOnce() + }) + }) + + describe('UI_InsertGraphic message', () => { + it('dispatches a file picker modal', async () => { + const { instance } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ MessageId: 'UI_InsertGraphic' }) + ) + + const { dispatchModal } = useModals() + expect(dispatchModal).toHaveBeenCalledOnce() + }) + + it('restricts allowed file types to images', async () => { + const { instance } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ MessageId: 'UI_InsertGraphic' }) + ) + + const { dispatchModal } = useModals() + const [modalOptions] = (dispatchModal as Mock).mock.calls[0] + expect(modalOptions.customComponentAttrs().allowedFileTypes).toEqual([ + 'image/png', + 'image/gif', + 'image/jpeg', + 'image/svg' + ]) + }) + }) + + describe('UI_InsertFile message', () => { + it('returns early when callback value is not a string', async () => { + const { instance } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ MessageId: 'UI_InsertFile', Values: { callback: 42 } }) + ) + + const { dispatchModal } = useModals() + expect(dispatchModal).not.toHaveBeenCalled() + }) + + it.each(['Action_CompareDocuments', 'Action_InsertFile'])( + 'dispatches modal for %s callback', + async (callback) => { + const { instance } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ + MessageId: 'UI_InsertFile', + Values: { callback } + }) + ) + + const { dispatchModal } = useModals() + expect(dispatchModal).toHaveBeenCalled() + } + ) + }) + + describe('UI_PickLink message', () => { + it('dispatches a file picker modal', async () => { + const { instance } = getWrapper() + + await instance.handlePostMessagesCollabora(createMessageEvent({ MessageId: 'UI_PickLink' })) + + const { dispatchModal } = useModals() + expect(dispatchModal).toHaveBeenCalledOnce() + }) + }) + + describe('UI_Mention message', () => { + describe('type: autocomplete', () => { + it('loads collaborators when not yet fetched', async () => { + const { instance } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ + MessageId: 'UI_Mention', + Values: { type: 'autocomplete', text: '' } + }) + ) + await flushPromises() + + expect(mockLoadSharesTask.perform).toHaveBeenCalledOnce() + }) + + it('does not reload collaborators on subsequent autocomplete calls', async () => { + const { instance } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ + MessageId: 'UI_Mention', + Values: { type: 'autocomplete', text: '' } + }) + ) + await flushPromises() + await instance.handlePostMessagesCollabora( + createMessageEvent({ + MessageId: 'UI_Mention', + Values: { type: 'autocomplete', text: '' } + }) + ) + await flushPromises() + + expect(mockLoadSharesTask.perform).toHaveBeenCalledOnce() + }) + + it('posts Action_Mention with collaborators matching the search text', async () => { + const postMessage = vi.fn() + const collaborators: CollaboratorShare[] = [ + mock({ + shareType: ShareTypes.user.value, + sharedWith: { id: 'user1', displayName: 'Alice Smith' } + }), + mock({ + shareType: ShareTypes.user.value, + sharedWith: { id: 'user2', displayName: 'Bob Jones' } + }) + ] + mockLoadSharesTask.perform.mockResolvedValue({ collaboratorShares: collaborators }) + + const { instance } = getWrapper({ appIframeRef: ref(createMockIframe(postMessage)) }) + + await instance.handlePostMessagesCollabora( + createMessageEvent({ + MessageId: 'UI_Mention', + Values: { type: 'autocomplete', text: 'alice' } + }) + ) + await flushPromises() + + const calls = getParsedPostMessageCalls(postMessage) + const mentionCall = calls.find((m) => m.MessageId === 'Action_Mention') + expect(mentionCall).toBeDefined() + const list = mentionCall!.Values!.list as Array<{ username: string }> + expect(list).toHaveLength(1) + expect(list[0].username).toBe('user1') + }) + + it('posts Action_Mention with all collaborators when search text is empty', async () => { + const postMessage = vi.fn() + const collaborators: CollaboratorShare[] = [ + mock({ + shareType: ShareTypes.user.value, + sharedWith: { id: 'user1', displayName: 'Alice Smith' } + }), + mock({ + shareType: ShareTypes.user.value, + sharedWith: { id: 'user2', displayName: 'Bob Jones' } + }) + ] + mockLoadSharesTask.perform.mockResolvedValue({ collaboratorShares: collaborators }) + + const { instance } = getWrapper({ appIframeRef: ref(createMockIframe(postMessage)) }) + + await instance.handlePostMessagesCollabora( + createMessageEvent({ + MessageId: 'UI_Mention', + Values: { type: 'autocomplete', text: '' } + }) + ) + await flushPromises() + + const calls = getParsedPostMessageCalls(postMessage) + const mentionCall = calls.find((m) => m.MessageId === 'Action_Mention') + const list = mentionCall!.Values!.list as Array<{ username: string }> + expect(list).toHaveLength(2) + }) + + it('excludes collaborators with non-individual share types', async () => { + const postMessage = vi.fn() + const collaborators: CollaboratorShare[] = [ + mock({ + shareType: ShareTypes.user.value, + sharedWith: { id: 'user1', displayName: 'Alice' } + }), + mock({ + shareType: ShareTypes.group.value, + sharedWith: { id: 'group1', displayName: 'Devs' } + }) + ] + mockLoadSharesTask.perform.mockResolvedValue({ collaboratorShares: collaborators }) + + const { instance } = getWrapper({ appIframeRef: ref(createMockIframe(postMessage)) }) + + await instance.handlePostMessagesCollabora( + createMessageEvent({ + MessageId: 'UI_Mention', + Values: { type: 'autocomplete', text: '' } + }) + ) + await flushPromises() + + const calls = getParsedPostMessageCalls(postMessage) + const mentionCall = calls.find((m) => m.MessageId === 'Action_Mention') + const list = mentionCall!.Values!.list as Array<{ username: string }> + expect(list).toHaveLength(1) + expect(list[0].username).toBe('user1') + }) + + it('deduplicates collaborators with the same user id', async () => { + const postMessage = vi.fn() + const collaborators: CollaboratorShare[] = [ + mock({ + shareType: ShareTypes.user.value, + sharedWith: { id: 'user1', displayName: 'Alice' } + }), + mock({ + shareType: ShareTypes.user.value, + sharedWith: { id: 'user1', displayName: 'Alice' } + }) + ] + mockLoadSharesTask.perform.mockResolvedValue({ collaboratorShares: collaborators }) + + const { instance } = getWrapper({ appIframeRef: ref(createMockIframe(postMessage)) }) + + await instance.handlePostMessagesCollabora( + createMessageEvent({ + MessageId: 'UI_Mention', + Values: { type: 'autocomplete', text: '' } + }) + ) + await flushPromises() + + const calls = getParsedPostMessageCalls(postMessage) + const mentionCall = calls.find((m) => m.MessageId === 'Action_Mention') + const list = mentionCall!.Values!.list as Array<{ username: string }> + expect(list).toHaveLength(1) + }) + }) + + describe('type: selected', () => { + it('sends userIDs of all unique selected users when notifying', async () => { + const { instance, mocks } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ + MessageId: 'UI_Mention', + Values: { type: 'selected', username: 'user1' } + }) + ) + await instance.handlePostMessagesCollabora( + createMessageEvent({ + MessageId: 'UI_Mention', + Values: { type: 'selected', username: 'user2' } + }) + ) + await instance.handlePostMessagesCollabora(createMessageEvent({ MessageId: 'UI_Close' })) + await flushPromises() + + const [, body] = mocks.$clientService.httpAuthenticated.post.mock.calls[0] as [ + string, + { userIDs: string[] } + ] + expect(body.userIDs).toEqual(['user1', 'user2']) + }) + + it('does not add duplicate user ids to the mention list', async () => { + const { instance, mocks } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ + MessageId: 'UI_Mention', + Values: { type: 'selected', username: 'user1' } + }) + ) + await instance.handlePostMessagesCollabora( + createMessageEvent({ + MessageId: 'UI_Mention', + Values: { type: 'selected', username: 'user1' } + }) + ) + await instance.handlePostMessagesCollabora(createMessageEvent({ MessageId: 'UI_Close' })) + await flushPromises() + + const [, body] = mocks.$clientService.httpAuthenticated.post.mock.calls[0] as [ + string, + { userIDs: string[] } + ] + expect(body.userIDs).toEqual(['user1']) + }) + }) + }) + + describe('resetMentionState', () => { + it('triggers a fresh collaborator load on the next autocomplete call', async () => { + mockLoadSharesTask.perform.mockResolvedValue({ collaboratorShares: [] }) + const { instance } = getWrapper() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ MessageId: 'UI_Mention', Values: { type: 'autocomplete', text: '' } }) + ) + await flushPromises() + instance.resetMentionState() + + await instance.handlePostMessagesCollabora( + createMessageEvent({ MessageId: 'UI_Mention', Values: { type: 'autocomplete', text: '' } }) + ) + await flushPromises() + + expect(mockLoadSharesTask.perform).toHaveBeenCalledTimes(2) + }) + + it('cancels a running loadSharesTask', () => { + mockLoadSharesTask.isRunning = true + const { instance } = getWrapper() + + instance.resetMentionState() + + expect(mockLoadSharesTask.cancelAll).toHaveBeenCalledOnce() + }) + }) + + describe('invalid message', () => { + it('handles malformed JSON without throwing', async () => { + const consoleDebug = vi.spyOn(console, 'debug').mockImplementation(() => undefined) + const { instance } = getWrapper() + + await expect( + instance.handlePostMessagesCollabora( + new MessageEvent('message', { data: 'not-valid-json{' }) + ) + ).resolves.toBeUndefined() + + expect(consoleDebug).toHaveBeenCalled() + }) + }) +}) + +function getWrapper({ + appIframeRef = ref(createMockIframe()), + space = ref(mock()), + resource = ref(mock({ name: 'test.odt', path: '/folder/test.odt' })), + mocks = defaultComponentMocks() +}: { + appIframeRef?: Ref + space?: Ref + resource?: Ref + mocks?: ReturnType +} = {}) { + let instance!: ReturnType + + const wrapper = getComposableWrapper( + () => { + instance = useCollaboraPostMessages({ space, resource, appIframeRef }) + }, + { + mocks, + provide: mocks, + pluginOptions: { + piniaOptions: { + configState: { server: 'https://example.com/' } + } + } + } + ) + + return { wrapper, instance, mocks } +} diff --git a/packages/web-pkg/src/components/SideBar/FileSideBar.vue b/packages/web-pkg/src/components/SideBar/FileSideBar.vue index 69c6df1fd5..27e16854c3 100644 --- a/packages/web-pkg/src/components/SideBar/FileSideBar.vue +++ b/packages/web-pkg/src/components/SideBar/FileSideBar.vue @@ -22,60 +22,41 @@