diff --git a/CHANGELOG.md b/CHANGELOG.md index 45ae147ad..5e7658e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to ### Added - ⚡️(frontend) add skeleton on content loading #2254 +- ⚡️(frontend) close websocket connection when user change tab #2264 ### Fixed diff --git a/env.d/development/common b/env.d/development/common index 252f10518..804a3976e 100644 --- a/env.d/development/common +++ b/env.d/development/common @@ -78,6 +78,7 @@ COLLABORATION_SERVER_ORIGIN=http://localhost:3000 COLLABORATION_SERVER_SECRET=my-secret COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=true COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/ +COLLABORATION_WS_INACTIVITY_TIMEOUT=15 # Seconds DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token Y_PROVIDER_API_BASE_URL=http://y-provider-development:4444/api/ diff --git a/src/frontend/apps/e2e/.env b/src/frontend/apps/e2e/.env index 608debf7a..1da7cdfed 100644 --- a/src/frontend/apps/e2e/.env +++ b/src/frontend/apps/e2e/.env @@ -2,7 +2,6 @@ PORT=3000 BASE_URL=http://localhost:3000 BASE_API_URL=http://localhost:8071/api/v1.0 COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/ -COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=true MEDIA_BASE_URL=http://localhost:8083 CUSTOM_SIGN_IN=false IS_INSTANCE=false diff --git a/src/frontend/apps/e2e/.env.example b/src/frontend/apps/e2e/.env.example index 081715ac1..52f7745da 100644 --- a/src/frontend/apps/e2e/.env.example +++ b/src/frontend/apps/e2e/.env.example @@ -2,7 +2,6 @@ PORT=3000 BASE_URL=http://localhost:3000 BASE_API_URL=http://localhost:8071/api/v1.0 COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/ -COLLABORATION_WS_NOT_CONNECTED_READY_ONLY=true MEDIA_BASE_URL=http://localhost:8083 IS_INSTANCE=false CUSTOM_SIGN_IN=false diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-collaboration.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-collaboration.spec.ts new file mode 100644 index 000000000..f17624f45 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-collaboration.spec.ts @@ -0,0 +1,352 @@ +import path from 'path'; + +import { expect, test } from '@playwright/test'; + +import { createDoc, overrideConfig, verifyDocName } from './utils-common'; +import { writeInEditor } from './utils-editor'; +import { connectOtherUserToDoc, updateShareLink } from './utils-share'; +import { createRootSubPage } from './utils-sub-pages'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Doc Collaboration', () => { + /** + * We check: + * - connection to the collaborative server + * - signal of the backend to the collaborative server (connection should close) + * - reconnection to the collaborative server + */ + test('checks the connection with collaborative server', async ({ page }) => { + let webSocketPromise = page.waitForEvent('websocket', (webSocket) => { + return webSocket + .url() + .includes(`${process.env.COLLABORATION_WS_URL}?room=`); + }); + + await page + .getByRole('button', { + name: 'New doc', + }) + .click(); + + let webSocket = await webSocketPromise; + expect(webSocket.url()).toContain( + `${process.env.COLLABORATION_WS_URL}?room=`, + ); + + // Is connected + let framesentPromise = webSocket.waitForEvent('framesent'); + + await writeInEditor({ page, text: 'Hello World' }); + + let framesent = await framesentPromise; + expect(framesent.payload).not.toBeNull(); + + await page.getByRole('button', { name: 'Share' }).click(); + + const selectVisibility = page.getByTestId('doc-visibility'); + + // When the visibility is changed, the ws should close the connection (backend signal) + const wsClosePromise = webSocket.waitForEvent('close'); + + await selectVisibility.click(); + await page.getByRole('menuitemradio', { name: 'Connected' }).click(); + + // Assert that the doc reconnects to the ws + const wsClose = await wsClosePromise; + expect(wsClose.isClosed()).toBeTruthy(); + + // Check the ws is connected again + webSocket = await page.waitForEvent('websocket', (webSocket) => { + return webSocket + .url() + .includes(`${process.env.COLLABORATION_WS_URL}?room=`); + }); + framesentPromise = webSocket.waitForEvent('framesent'); + framesent = await framesentPromise; + expect(framesent.payload).not.toBeNull(); + }); + + test('it cannot edit if viewer but see and can get resources', async ({ + page, + browserName, + }) => { + const [docTitle] = await createDoc(page, 'doc-viewer', browserName, 1); + await verifyDocName(page, docTitle); + + await writeInEditor({ page, text: 'Hello World' }); + + await page.getByRole('button', { name: 'Share' }).click(); + await updateShareLink(page, 'Public', 'Reading'); + + // Close the modal + await page.getByRole('button', { name: 'close' }).first().click(); + + const { otherPage, cleanup } = await connectOtherUserToDoc({ + browserName, + docUrl: page.url(), + withoutSignIn: true, + docTitle, + }); + + await expect( + otherPage.getByLabel('It is the card information').getByText('Reader'), + ).toBeVisible(); + + // Cannot edit + const editor = otherPage.locator('.ProseMirror'); + await expect(editor).toHaveAttribute('contenteditable', 'false'); + + // Owner add a image + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Resizable image with caption').click(); + await page.getByText('Upload image').click(); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles( + path.join(__dirname, 'assets/logo-suite-numerique.png'), + ); + + // Owner see the image + await expect( + page.locator('.--docs--editor-container img.bn-visual-media').first(), + ).toBeVisible(); + + // Viewser see the image + const viewerImg = otherPage + .locator('.--docs--editor-container img.bn-visual-media') + .first(); + await expect(viewerImg).toBeVisible({ + timeout: 10000, + }); + + // Viewer can download the image + await viewerImg.click(); + const downloadPromise = otherPage.waitForEvent('download'); + await otherPage.getByRole('button', { name: 'Download image' }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe('logo-suite-numerique.png'); + + await cleanup(); + }); + + test('it checks block editing when not connected to collab server', async ({ + page, + browserName, + }) => { + test.slow(); + + /** + * The good port is 4444, but we want to simulate a not connected + * collaborative server. + * So we use a port that is not used by the collaborative server. + * The server will not be able to connect to the collaborative server. + */ + await overrideConfig(page, { + COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/', + COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true, + }); + + await page.goto('/'); + + const [parentTitle] = await createDoc( + page, + 'editing-blocking', + browserName, + 1, + ); + + const card = page.getByLabel('It is the card information'); + await expect( + card.getByText('Others are editing. Your network prevent changes.'), + ).toBeHidden(); + const editor = page.locator('.ProseMirror'); + + await expect(editor).toHaveAttribute('contenteditable', 'true'); + + let responseCanEditPromise = page.waitForResponse( + (response) => + response.url().includes(`/can-edit/`) && response.status() === 200, + ); + + await page.getByRole('button', { name: 'Share' }).click(); + + await updateShareLink(page, 'Public', 'Editing'); + + // Close the modal + await page.getByRole('button', { name: 'close' }).first().click(); + + const urlParentDoc = page.url(); + + const { name: childTitle } = await createRootSubPage( + page, + browserName, + 'editing-blocking - child', + ); + + let responseCanEdit = await responseCanEditPromise; + expect(responseCanEdit.ok()).toBeTruthy(); + let jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean }; + expect(jsonCanEdit.can_edit).toBeTruthy(); + + const urlChildDoc = page.url(); + + /** + * We open another browser that will connect to the collaborative server + * and will block the current browser to edit the doc. + */ + const { otherPage, cleanup } = await connectOtherUserToDoc({ + browserName, + docUrl: urlChildDoc, + docTitle: childTitle, + withoutSignIn: true, + }); + + const webSocketPromise = otherPage.waitForEvent( + 'websocket', + (webSocket) => { + return webSocket + .url() + .includes(`${process.env.COLLABORATION_WS_URL}?room=`); + }, + ); + + await otherPage.goto(urlChildDoc); + + const webSocket = await webSocketPromise; + expect(webSocket.url()).toContain( + `${process.env.COLLABORATION_WS_URL}?room=`, + ); + + await verifyDocName(otherPage, childTitle); + + await page.reload(); + + responseCanEdit = await page.waitForResponse( + (response) => + response.url().includes(`/can-edit/`) && response.status() === 200, + ); + expect(responseCanEdit.ok()).toBeTruthy(); + + jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean }; + expect(jsonCanEdit.can_edit).toBeFalsy(); + + await expect( + card.getByText('Others are editing. Your network prevent changes.'), + ).toBeVisible({ + timeout: 10000, + }); + + await expect(editor).toHaveAttribute('contenteditable', 'false'); + + await expect( + page.getByRole('textbox', { name: 'Document title' }), + ).toBeHidden(); + await expect(page.getByRole('heading', { name: childTitle })).toBeVisible(); + + await page.goto(urlParentDoc); + + await verifyDocName(page, parentTitle); + + await page.getByRole('button', { name: 'Share' }).click(); + + await page.getByTestId('doc-access-mode').click(); + await page.getByRole('menuitemradio', { name: 'Reading' }).click(); + + // Close the modal + await page.getByRole('button', { name: 'close' }).first().click(); + + await page.goto(urlChildDoc); + + await expect(editor).toHaveAttribute('contenteditable', 'true'); + + await expect( + page.getByRole('textbox', { name: 'Document title' }), + ).toContainText(childTitle); + await expect(page.getByRole('heading', { name: childTitle })).toBeHidden(); + + await expect( + card.getByText('Others are editing. Your network prevent changes.'), + ).toBeHidden(); + + await cleanup(); + }); + + test('checks disconnection and reconnection when changing tab visibility', async ({ + page, + }) => { + await overrideConfig(page, { + COLLABORATION_WS_INACTIVITY_TIMEOUT: 2, // 2 seconds for the test to be faster + }); + + await page.goto('/'); + + let webSocketPromise = page.waitForEvent('websocket', (webSocket) => { + return webSocket + .url() + .includes(`${process.env.COLLABORATION_WS_URL}?room=`); + }); + + await page + .getByRole('button', { + name: 'New doc', + }) + .click(); + + let webSocket = await webSocketPromise; + expect(webSocket.url()).toContain( + `${process.env.COLLABORATION_WS_URL}?room=`, + ); + + // Is connected + let framesentPromise = webSocket.waitForEvent('framesent'); + + await writeInEditor({ page, text: 'Hello World' }); + + let framesent = await framesentPromise; + expect(framesent.payload).not.toBeNull(); + + // When the visibility is changed, the ws should close the connection + const wsClosePromise = webSocket.waitForEvent('close'); + + // Simulate the tab being hidden + await page.evaluate(() => { + Object.defineProperty(document, 'hidden', { + value: true, + writable: true, + configurable: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + }); + + // Assert the ws connection is closed after inactivity timeout + const wsClose = await wsClosePromise; + expect(wsClose.isClosed()).toBeTruthy(); + + // Check the ws is connected again + webSocketPromise = page.waitForEvent('websocket', (webSocket) => { + return webSocket + .url() + .includes(`${process.env.COLLABORATION_WS_URL}?room=`); + }); + + // Simulate the tab becoming visible again + await page.evaluate(() => { + Object.defineProperty(document, 'hidden', { + value: false, + writable: true, + configurable: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + }); + + webSocket = await webSocketPromise; + framesentPromise = webSocket.waitForEvent('framesent'); + framesent = await framesentPromise; + // Assert the ws connection is working again + expect(framesent.payload).not.toBeNull(); + }); +}); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index d86e4216f..7407027fd 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -3,14 +3,9 @@ import path from 'path'; import { expect, test } from '@playwright/test'; import cs from 'convert-stream'; -import { - createDoc, - goToGridDoc, - overrideConfig, - verifyDocName, -} from './utils-common'; +import { createDoc, goToGridDoc, verifyDocName } from './utils-common'; import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor'; -import { connectOtherUserToDoc, updateShareLink } from './utils-share'; +import { updateShareLink } from './utils-share'; import { createRootSubPage, getTreeRow, @@ -111,63 +106,6 @@ test.describe('Doc Editor', () => { ).toBeVisible(); }); - /** - * We check: - * - connection to the collaborative server - * - signal of the backend to the collaborative server (connection should close) - * - reconnection to the collaborative server - */ - test('checks the connection with collaborative server', async ({ page }) => { - let webSocketPromise = page.waitForEvent('websocket', (webSocket) => { - return webSocket - .url() - .includes(`${process.env.COLLABORATION_WS_URL}?room=`); - }); - - await page - .getByRole('button', { - name: 'New doc', - }) - .click(); - - let webSocket = await webSocketPromise; - expect(webSocket.url()).toContain( - `${process.env.COLLABORATION_WS_URL}?room=`, - ); - - // Is connected - let framesentPromise = webSocket.waitForEvent('framesent'); - - await writeInEditor({ page, text: 'Hello World' }); - - let framesent = await framesentPromise; - expect(framesent.payload).not.toBeNull(); - - await page.getByRole('button', { name: 'Share' }).click(); - - const selectVisibility = page.getByTestId('doc-visibility'); - - // When the visibility is changed, the ws should close the connection (backend signal) - const wsClosePromise = webSocket.waitForEvent('close'); - - await selectVisibility.click(); - await page.getByRole('menuitemradio', { name: 'Connected' }).click(); - - // Assert that the doc reconnects to the ws - const wsClose = await wsClosePromise; - expect(wsClose.isClosed()).toBeTruthy(); - - // Check the ws is connected again - webSocket = await page.waitForEvent('websocket', (webSocket) => { - return webSocket - .url() - .includes(`${process.env.COLLABORATION_WS_URL}?room=`); - }); - framesentPromise = webSocket.waitForEvent('framesent'); - framesent = await framesentPromise; - expect(framesent.payload).not.toBeNull(); - }); - test('markdown button converts from markdown to the editor syntax json', async ({ page, browserName, @@ -285,70 +223,6 @@ test.describe('Doc Editor', () => { await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible(); }); - test('it cannot edit if viewer but see and can get resources', async ({ - page, - browserName, - }) => { - const [docTitle] = await createDoc(page, 'doc-viewer', browserName, 1); - await verifyDocName(page, docTitle); - - await writeInEditor({ page, text: 'Hello World' }); - - await page.getByRole('button', { name: 'Share' }).click(); - await updateShareLink(page, 'Public', 'Reading'); - - // Close the modal - await page.getByRole('button', { name: 'close' }).first().click(); - - const { otherPage, cleanup } = await connectOtherUserToDoc({ - browserName, - docUrl: page.url(), - withoutSignIn: true, - docTitle, - }); - - await expect( - otherPage.getByLabel('It is the card information').getByText('Reader'), - ).toBeVisible(); - - // Cannot edit - const editor = otherPage.locator('.ProseMirror'); - await expect(editor).toHaveAttribute('contenteditable', 'false'); - - // Owner add a image - const fileChooserPromise = page.waitForEvent('filechooser'); - await page.locator('.bn-block-outer').last().fill('/'); - await page.getByText('Resizable image with caption').click(); - await page.getByText('Upload image').click(); - - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles( - path.join(__dirname, 'assets/logo-suite-numerique.png'), - ); - - // Owner see the image - await expect( - page.locator('.--docs--editor-container img.bn-visual-media').first(), - ).toBeVisible(); - - // Viewser see the image - const viewerImg = otherPage - .locator('.--docs--editor-container img.bn-visual-media') - .first(); - await expect(viewerImg).toBeVisible({ - timeout: 10000, - }); - - // Viewer can download the image - await viewerImg.click(); - const downloadPromise = otherPage.waitForEvent('download'); - await otherPage.getByRole('button', { name: 'Download image' }).click(); - const download = await downloadPromise; - expect(download.suggestedFilename()).toBe('logo-suite-numerique.png'); - - await cleanup(); - }); - test('it adds an image to the doc editor', async ({ page, browserName }) => { await createDoc(page, 'doc-image', browserName, 1); @@ -493,151 +367,6 @@ test.describe('Doc Editor', () => { await expect(editor.getByText('Analyzing file...')).toBeHidden(); }); - if (process.env.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY === 'true') { - test('it checks block editing when not connected to collab server', async ({ - page, - browserName, - }) => { - test.slow(); - - /** - * The good port is 4444, but we want to simulate a not connected - * collaborative server. - * So we use a port that is not used by the collaborative server. - * The server will not be able to connect to the collaborative server. - */ - await overrideConfig(page, { - COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/', - }); - - await page.goto('/'); - - const [parentTitle] = await createDoc( - page, - 'editing-blocking', - browserName, - 1, - ); - - const card = page.getByLabel('It is the card information'); - await expect( - card.getByText('Others are editing. Your network prevent changes.'), - ).toBeHidden(); - const editor = page.locator('.ProseMirror'); - - await expect(editor).toHaveAttribute('contenteditable', 'true'); - - let responseCanEditPromise = page.waitForResponse( - (response) => - response.url().includes(`/can-edit/`) && response.status() === 200, - ); - - await page.getByRole('button', { name: 'Share' }).click(); - - await updateShareLink(page, 'Public', 'Editing'); - - // Close the modal - await page.getByRole('button', { name: 'close' }).first().click(); - - const urlParentDoc = page.url(); - - const { name: childTitle } = await createRootSubPage( - page, - browserName, - 'editing-blocking - child', - ); - - let responseCanEdit = await responseCanEditPromise; - expect(responseCanEdit.ok()).toBeTruthy(); - let jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean }; - expect(jsonCanEdit.can_edit).toBeTruthy(); - - const urlChildDoc = page.url(); - - /** - * We open another browser that will connect to the collaborative server - * and will block the current browser to edit the doc. - */ - const { otherPage } = await connectOtherUserToDoc({ - browserName, - docUrl: urlChildDoc, - docTitle: childTitle, - withoutSignIn: true, - }); - - const webSocketPromise = otherPage.waitForEvent( - 'websocket', - (webSocket) => { - return webSocket - .url() - .includes(`${process.env.COLLABORATION_WS_URL}?room=`); - }, - ); - - await otherPage.goto(urlChildDoc); - - const webSocket = await webSocketPromise; - expect(webSocket.url()).toContain( - `${process.env.COLLABORATION_WS_URL}?room=`, - ); - - await verifyDocName(otherPage, childTitle); - - await page.reload(); - - responseCanEdit = await page.waitForResponse( - (response) => - response.url().includes(`/can-edit/`) && response.status() === 200, - ); - expect(responseCanEdit.ok()).toBeTruthy(); - - jsonCanEdit = (await responseCanEdit.json()) as { can_edit: boolean }; - expect(jsonCanEdit.can_edit).toBeFalsy(); - - await expect( - card.getByText('Others are editing. Your network prevent changes.'), - ).toBeVisible({ - timeout: 10000, - }); - - await expect(editor).toHaveAttribute('contenteditable', 'false'); - - await expect( - page.getByRole('textbox', { name: 'Document title' }), - ).toBeHidden(); - await expect( - page.getByRole('heading', { name: childTitle }), - ).toBeVisible(); - - await page.goto(urlParentDoc); - - await verifyDocName(page, parentTitle); - - await page.getByRole('button', { name: 'Share' }).click(); - - await page.getByTestId('doc-access-mode').click(); - await page.getByRole('menuitemradio', { name: 'Reading' }).click(); - - // Close the modal - await page.getByRole('button', { name: 'close' }).first().click(); - - await page.goto(urlChildDoc); - - await expect(editor).toHaveAttribute('contenteditable', 'true'); - - await expect( - page.getByRole('textbox', { name: 'Document title' }), - ).toContainText(childTitle); - await expect( - page.getByRole('heading', { name: childTitle }), - ).toBeHidden(); - - await expect( - card.getByText('Others are editing. Your network prevent changes.'), - ).toBeHidden(); - }); - } - test('it checks if callout custom block', async ({ page, browserName }) => { await createDoc(page, 'doc-toolbar', browserName, 1); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index 1ed9b61a1..0a7dc3acf 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -18,6 +18,7 @@ export const CONFIG = { AI_FEATURE_LEGACY_ENABLED: true, API_USERS_SEARCH_QUERY_MIN_LENGTH: 3, CRISP_WEBSITE_ID: null, + COLLABORATION_WS_INACTIVITY_TIMEOUT: 15, COLLABORATION_WS_URL: process.env.COLLABORATION_WS_URL, COLLABORATION_WS_NOT_CONNECTED_READY_ONLY: true, CONVERSION_UPLOAD_ENABLED: true, diff --git a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx index 0907a30cb..d41fb0d0b 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -42,6 +42,7 @@ export interface ConfigResponse { API_USERS_SEARCH_QUERY_MIN_LENGTH?: number; COLLABORATION_WS_URL?: string; COLLABORATION_WS_NOT_CONNECTED_READY_ONLY?: boolean; + COLLABORATION_WS_INACTIVITY_TIMEOUT?: number | null; CONVERSION_FILE_EXTENSIONS_ALLOWED: string[]; CONVERSION_FILE_MAX_SIZE: number; CONVERSION_UPLOAD_ENABLED?: boolean; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useCollaboration.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useCollaboration.tsx index c56bd55b5..e32c41515 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useCollaboration.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useCollaboration.tsx @@ -1,7 +1,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useEffect } from 'react'; -import { useCollaborationUrl } from '@/core/config'; +import { useCollaborationUrl, useConfig } from '@/core/config'; import { KEY_DOC } from '@/docs/doc-management/api/useDoc'; import { KEY_DOC_CONTENT, @@ -15,6 +15,7 @@ export const useCollaboration = (room: string) => { const collaborationUrl = useCollaborationUrl(room); const { addTask } = useBroadcastStore(); const queryClient = useQueryClient(); + const { data: config } = useConfig(); const { setBroadcastProvider, cleanupBroadcast, @@ -28,6 +29,8 @@ export const useCollaboration = (room: string) => { isReady, hasLostConnection, resetLostConnection, + pauseForInactivity, + resumeFromInactivity, } = useProviderStore(); const isOffline = useIsOffline((state) => state.isOffline); const { data: docContent } = useDocContent( @@ -109,4 +112,43 @@ export const useCollaboration = (room: string) => { } }; }, [destroyProvider, room, cleanupBroadcast]); + + useEffect(() => { + if (!provider || !config?.COLLABORATION_WS_INACTIVITY_TIMEOUT) { + return; + } + + const timeoutMs = config.COLLABORATION_WS_INACTIVITY_TIMEOUT * 1000; + let inactivityTimeout: ReturnType | undefined; + + const startInactivityTimer = () => { + clearTimeout(inactivityTimeout); + inactivityTimeout = setTimeout(pauseForInactivity, timeoutMs); + }; + + if (document.hidden) { + startInactivityTimer(); + } + + const visibilityChangeHandler = () => { + if (document.hidden) { + startInactivityTimer(); + } else { + clearTimeout(inactivityTimeout); + resumeFromInactivity(); + } + }; + + document.addEventListener('visibilitychange', visibilityChangeHandler); + + return () => { + document.removeEventListener('visibilitychange', visibilityChangeHandler); + clearTimeout(inactivityTimeout); + }; + }, [ + pauseForInactivity, + provider, + resumeFromInactivity, + config?.COLLABORATION_WS_INACTIVITY_TIMEOUT, + ]); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx index 829d751fb..5411cee6f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx @@ -13,11 +13,14 @@ export interface UseCollaborationStore { ) => HocuspocusProvider; destroyProvider: () => void; setReady: (value: boolean) => void; + pauseForInactivity: () => void; + resumeFromInactivity: () => void; provider: HocuspocusProvider | undefined; isConnected: boolean; isReady: boolean; isSynced: boolean; hasLostConnection: boolean; + isPausedForInactivity: boolean; resetLostConnection: () => void; } @@ -27,6 +30,7 @@ const defaultValues = { isReady: false, isSynced: false, hasLostConnection: false, + isPausedForInactivity: false, }; type ExtendedCloseEvent = CloseEvent & { wasClean: boolean }; @@ -59,6 +63,12 @@ export const useProviderStore = create((set, get) => ({ name: storeId, document: doc, onDisconnect(data) { + // Skip reconnect when the disconnect was triggered by inactivity: + // reconnection only happens once the user becomes active again. + if (get().isPausedForInactivity) { + return; + } + // Attempt to reconnect if the disconnection was clean (initiated by the client or server) if ((data.event as ExtendedCloseEvent).wasClean) { if (data.event.reason === 'No cookies' && data.event.code === 4001) { @@ -99,7 +109,7 @@ export const useProviderStore = create((set, get) => ({ } // If we were previously connected and now we're not, // we might have lost the connection - else if (wasConnected) { + else if (wasConnected && !get().isPausedForInactivity) { clearTimeout(lostConnectionTimeout); // Jitter spreading for reconnection attempts // Math.random() generates a random delay to avoid all clients @@ -163,5 +173,22 @@ export const useProviderStore = create((set, get) => ({ set(defaultValues); }, setReady: (value: boolean) => set({ isReady: value }), + pauseForInactivity: () => { + if (get().isPausedForInactivity) { + return; + } + clearTimeout(reconnectTimeout); + clearTimeout(lostConnectionTimeout); + set({ isPausedForInactivity: true, hasLostConnection: false }); + get().provider?.disconnect(); + }, + resumeFromInactivity: () => { + if (!get().isPausedForInactivity) { + return; + } + clearTimeout(lostConnectionTimeout); + set({ isPausedForInactivity: false }); + void get().provider?.connect(); + }, resetLostConnection: () => set({ hasLostConnection: false }), }));