From a166716a2f5f13a6bc6da2eed2f1714c0b796999 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 6 May 2026 15:21:16 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F(frontend)=20close=20websocke?= =?UTF-8?q?t=20connection=20when=20user=20change=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user change to another tab, after a delay of "inactivity" we disconnect the user from the collaboration server. When the user come back we reconnect to the server again. It will reduce the connection to the collaboration server and reduce outburst during reconnection during a ingress ngnix restart. --- CHANGELOG.md | 1 + env.d/development/common | 1 + src/frontend/apps/e2e/.env | 1 - src/frontend/apps/e2e/.env.example | 1 - .../app-impress/doc-collaboration.spec.ts | 352 ++++++++++++++++++ .../__tests__/app-impress/doc-editor.spec.ts | 275 +------------- .../e2e/__tests__/app-impress/utils-common.ts | 1 + .../impress/src/core/config/api/useConfig.tsx | 1 + .../docs/doc-editor/hook/useCollaboration.tsx | 44 ++- .../stores/useProviderStore.tsx | 29 +- 10 files changed, 429 insertions(+), 277 deletions(-) create mode 100644 src/frontend/apps/e2e/__tests__/app-impress/doc-collaboration.spec.ts 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 }), }));