diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acabdb32..3b611656d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to - 🚩(frontend) version MIT only #911 - ✨(backend) integrate maleware_detection from django-lasuite #936 - 🩺(CI) add lint spell mistakes #954 +- 🛂(frontend) block edition to not connected users #945 ## Changed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index 8217dc1bd..09e1ce749 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -102,7 +102,7 @@ export const verifyDocName = async (page: Page, docName: string) => { export const addNewMember = async ( page: Page, index: number, - role: 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader', + role: 'Administrator' | 'Owner' | 'Editor' | 'Reader', fillText: string = 'user ', ) => { const responsePromiseSearchUser = page.waitForResponse( 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 d1a8d8567..87d13c802 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 @@ -4,6 +4,8 @@ import { expect, test } from '@playwright/test'; import cs from 'convert-stream'; import { + CONFIG, + addNewMember, createDoc, goToGridDoc, mockedDocument, @@ -363,7 +365,7 @@ test.describe('Doc Editor', () => { partial_update: true, retrieve: true, }, - link_reach: 'public', + link_reach: 'restricted', link_role: 'editor', created_at: '2021-09-01T09:00:00Z', title: '', @@ -453,6 +455,55 @@ test.describe('Doc Editor', () => { expect(svgBuffer.toString()).toContain('Hello svg'); }); + test('it checks block editing when not connected to collab server', async ({ + page, + }) => { + await page.route('**/api/v1.0/config/', async (route) => { + const request = route.request(); + if (request.method().includes('GET')) { + await route.fulfill({ + json: { + ...CONFIG, + COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/', + }, + }); + } else { + await route.continue(); + } + }); + + await page.goto('/'); + + void page + .getByRole('button', { + name: 'New doc', + }) + .click(); + + const card = page.getByLabel('It is the card information'); + await expect( + card.getByText('Your network do not allow you to edit'), + ).toBeHidden(); + const editor = page.locator('.ProseMirror'); + + await expect(editor).toHaveAttribute('contenteditable', 'true'); + + await page.getByRole('button', { name: 'Share' }).click(); + + await addNewMember(page, 0, 'Editor', 'impress'); + + // Close the modal + await page.getByRole('button', { name: 'close' }).first().click(); + + await expect( + card.getByText('Your network do not allow you to edit'), + ).toBeVisible({ + timeout: 10000, + }); + + await expect(editor).toHaveAttribute('contenteditable', 'false'); + }); + test('it checks if callout custom block', async ({ page, browserName }) => { await createDoc(page, 'doc-toolbar', browserName, 1); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index a927578cf..e4240a02f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'; import * as Y from 'yjs'; import { Box, TextErrors } from '@/components'; -import { Doc } from '@/docs/doc-management'; +import { Doc, useIsCollaborativeEditable } from '@/docs/doc-management'; import { useAuth } from '@/features/auth'; import { useUploadFile } from '../hook'; @@ -49,7 +49,9 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const { setEditor } = useEditorStore(); const { t } = useTranslation(); - const readOnly = !doc.abilities.partial_update; + const { isEditable, isLoading } = useIsCollaborativeEditable(doc); + const readOnly = !doc.abilities.partial_update || !isEditable || isLoading; + useSaveDoc(doc.id, provider.document, !readOnly); const { i18n } = useTranslation(); const lang = i18n.resolvedLanguage; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx index d7e23aa50..6f07096e6 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx @@ -25,7 +25,6 @@ interface DocEditorProps { export const DocEditor = ({ doc, versionId }: DocEditorProps) => { const { isDesktop } = useResponsiveStore(); - const isVersion = !!versionId && typeof versionId === 'string'; const { colorsTokens } = useCunninghamTheme(); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx new file mode 100644 index 000000000..ac2303109 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertNetwork.tsx @@ -0,0 +1,113 @@ +import { Button, Modal, ModalSize } from '@openfun/cunningham-react'; +import { t } from 'i18next'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { Box, BoxButton, Icon, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +export const AlertNetwork = () => { + const { t } = useTranslation(); + const { colorsTokens, spacingsTokens } = useCunninghamTheme(); + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + <> + + + + + + {t('Your network do not allow you to edit')} + + + setIsModalOpen(true)} + > + + + {t('Know more')} + + + + + {isModalOpen && ( + setIsModalOpen(false)} /> + )} + + ); +}; + +interface AlertNetworkModalProps { + onClose: () => void; +} + +export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => { + return ( + onClose()} + rightActions={ + <> + + + } + size={ModalSize.MEDIUM} + title={ + + {t("Why can't I edit?")} + + } + > + + + {t( + 'The network configuration of your workstation or internet connection does not allow editing shared documents.', + )} + + + {t( + 'Docs use WebSockets to enable real-time editing. These communication channels allow instant and bidirectional exchanges between your browser and our servers. To access collaborative editing, please contact your IT department to enable WebSockets.', + )} + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertPublic.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertPublic.tsx new file mode 100644 index 000000000..de01fa57b --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/AlertPublic.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { Box, Icon, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +export const AlertPublic = ({ isPublicDoc }: { isPublicDoc: boolean }) => { + const { t } = useTranslation(); + const { colorsTokens, spacingsTokens } = useCunninghamTheme(); + + return ( + + + + {isPublicDoc + ? t('Public document') + : t('Document accessible to any connected person')} + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx index 8aebcd17a..cae723092 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx @@ -1,17 +1,20 @@ import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; -import { css } from 'styled-components'; -import { Box, HorizontalSeparator, Icon, Text } from '@/components'; +import { Box, HorizontalSeparator, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; import { Doc, LinkReach, + Role, currentDocRole, + useIsCollaborativeEditable, useTrans, } from '@/docs/doc-management'; import { useResponsiveStore } from '@/stores'; +import { AlertNetwork } from './AlertNetwork'; +import { AlertPublic } from './AlertPublic'; import { DocTitle } from './DocTitle'; import { DocToolBox } from './DocToolBox'; @@ -20,51 +23,26 @@ interface DocHeaderProps { } export const DocHeader = ({ doc }: DocHeaderProps) => { - const { colorsTokens, spacingsTokens } = useCunninghamTheme(); + const { spacingsTokens } = useCunninghamTheme(); const { isDesktop } = useResponsiveStore(); - const { t } = useTranslation(); + const { transRole } = useTrans(); + const { isEditable } = useIsCollaborativeEditable(doc); const docIsPublic = doc.link_reach === LinkReach.PUBLIC; const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED; - const { transRole } = useTrans(); - return ( <> + {!isEditable && } {(docIsPublic || docIsAuth) && ( - - - - {docIsPublic - ? t('Public document') - : t('Document accessible to any connected person')} - - + )} { {isDesktop && ( <> - - {transRole(currentDocRole(doc.abilities))} Â·  + + {transRole( + isEditable + ? currentDocRole(doc.abilities) + : Role.READER, + )} +  Â·  {t('Last update: {{update}}', { diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts index a1937ba07..96968e381 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts @@ -1,3 +1,4 @@ export * from './useCollaboration'; -export * from './useTrans'; export * from './useCopyDocLink'; +export * from './useIsCollaborativeEditable'; +export * from './useTrans'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx new file mode 100644 index 000000000..b5f97f587 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useIsCollaborativeEditable.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; + +import { useIsOffline } from '@/features/service-worker'; + +import { useProviderStore } from '../stores'; +import { Doc, LinkReach } from '../types'; + +export const useIsCollaborativeEditable = (doc: Doc) => { + const { isConnected } = useProviderStore(); + + const docIsPublic = doc.link_reach === LinkReach.PUBLIC; + const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED; + const docHasMember = doc.nb_accesses_direct > 1; + const isShared = docIsPublic || docIsAuth || docHasMember; + const [isEditable, setIsEditable] = useState(true); + const [isLoading, setIsLoading] = useState(true); + const { isOffline } = useIsOffline(); + + /** + * Connection can take a few seconds + */ + useEffect(() => { + const _isEditable = isConnected || !isShared || isOffline; + setIsLoading(true); + + if (_isEditable) { + setIsEditable(true); + setIsLoading(false); + return; + } + + const timer = setTimeout(() => { + setIsEditable(false); + setIsLoading(false); + }, 5000); + + return () => clearTimeout(timer); + }, [isConnected, isOffline, isShared]); + + return { + isEditable, + isLoading, + }; +}; 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 fb78eb6ec..ae7ed1517 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 @@ -1,4 +1,4 @@ -import { HocuspocusProvider } from '@hocuspocus/provider'; +import { HocuspocusProvider, WebSocketStatus } from '@hocuspocus/provider'; import * as Y from 'yjs'; import { create } from 'zustand'; @@ -12,10 +12,12 @@ export interface UseCollaborationStore { ) => HocuspocusProvider; destroyProvider: () => void; provider: HocuspocusProvider | undefined; + isConnected: boolean; } const defaultValues = { provider: undefined, + isConnected: false, }; export const useProviderStore = create((set, get) => ({ @@ -33,6 +35,11 @@ export const useProviderStore = create((set, get) => ({ url: wsUrl, name: storeId, document: doc, + onStatus: ({ status }) => { + set({ + isConnected: status === WebSocketStatus.Connected, + }); + }, }); set({ diff --git a/src/frontend/apps/impress/src/features/service-worker/__tests__/ApiPlugin.test.tsx b/src/frontend/apps/impress/src/features/service-worker/__tests__/ApiPlugin.test.tsx index f9fdfae02..198fc91ff 100644 --- a/src/frontend/apps/impress/src/features/service-worker/__tests__/ApiPlugin.test.tsx +++ b/src/frontend/apps/impress/src/features/service-worker/__tests__/ApiPlugin.test.tsx @@ -4,8 +4,8 @@ import '@testing-library/jest-dom'; -import { ApiPlugin } from '../ApiPlugin'; import { RequestSerializer } from '../RequestSerializer'; +import { ApiPlugin } from '../plugins/ApiPlugin'; const mockedGet = jest.fn().mockResolvedValue({}); const mockedGetAllKeys = jest.fn().mockResolvedValue([]); diff --git a/src/frontend/apps/impress/src/features/service-worker/__tests__/OfflinePlugin.test.tsx b/src/frontend/apps/impress/src/features/service-worker/__tests__/OfflinePlugin.test.tsx new file mode 100644 index 000000000..183634e58 --- /dev/null +++ b/src/frontend/apps/impress/src/features/service-worker/__tests__/OfflinePlugin.test.tsx @@ -0,0 +1,65 @@ +/** + * @jest-environment node + */ + +import '@testing-library/jest-dom'; + +import { MESSAGE_TYPE } from '../conf'; +import { OfflinePlugin } from '../plugins/OfflinePlugin'; + +const mockServiceWorkerScope = { + clients: { + matchAll: jest.fn().mockResolvedValue([]), + }, +} as unknown as ServiceWorkerGlobalScope; + +(global as any).self = { + ...global, + clients: mockServiceWorkerScope.clients, +} as unknown as ServiceWorkerGlobalScope; + +describe('OfflinePlugin', () => { + afterEach(() => jest.clearAllMocks()); + + it(`calls fetchDidSucceed`, async () => { + const apiPlugin = new OfflinePlugin(); + const postMessageSpy = jest.spyOn(apiPlugin, 'postMessage'); + + await apiPlugin.fetchDidSucceed?.({ + response: new Response(), + } as any); + + expect(postMessageSpy).toHaveBeenCalledWith(false, 'fetchDidSucceed'); + }); + + it(`calls fetchDidFail`, async () => { + const apiPlugin = new OfflinePlugin(); + const postMessageSpy = jest.spyOn(apiPlugin, 'postMessage'); + + await apiPlugin.fetchDidFail?.({} as any); + + expect(postMessageSpy).toHaveBeenCalledWith(true, 'fetchDidFail'); + }); + + it(`calls postMessage`, async () => { + const apiPlugin = new OfflinePlugin(); + const mockClients = [ + { postMessage: jest.fn() }, + { postMessage: jest.fn() }, + ]; + + mockServiceWorkerScope.clients.matchAll = jest + .fn() + .mockResolvedValue(mockClients); + + await apiPlugin.postMessage(false, 'testMessage'); + + for (const client of mockClients) { + expect(client.postMessage).toHaveBeenCalledWith({ + type: MESSAGE_TYPE.OFFLINE, + value: false, + message: 'testMessage', + }); + } + }); +}); diff --git a/src/frontend/apps/impress/src/features/service-worker/__tests__/useOffline.test.tsx b/src/frontend/apps/impress/src/features/service-worker/__tests__/useOffline.test.tsx new file mode 100644 index 000000000..a2920ce2a --- /dev/null +++ b/src/frontend/apps/impress/src/features/service-worker/__tests__/useOffline.test.tsx @@ -0,0 +1,63 @@ +import '@testing-library/jest-dom'; +import { act, renderHook } from '@testing-library/react'; + +import { MESSAGE_TYPE } from '../conf'; +import { useIsOffline, useOffline } from '../hooks/useOffline'; + +const mockAddEventListener = jest.fn(); +const mockRemoveEventListener = jest.fn(); +Object.defineProperty(navigator, 'serviceWorker', { + value: { + addEventListener: mockAddEventListener, + removeEventListener: mockRemoveEventListener, + }, + writable: true, +}); + +describe('useOffline', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should set isOffline to true when receiving an offline message', () => { + useIsOffline.setState({ isOffline: false }); + + const { result } = renderHook(() => useIsOffline()); + renderHook(() => useOffline()); + + act(() => { + const messageEvent = { + data: { + type: MESSAGE_TYPE.OFFLINE, + value: true, + message: 'Offline', + }, + }; + + mockAddEventListener.mock.calls[0][1](messageEvent); + }); + + expect(result.current.isOffline).toBe(true); + }); + + it('should set isOffline to false when receiving an online message', () => { + useIsOffline.setState({ isOffline: false }); + + const { result } = renderHook(() => useIsOffline()); + renderHook(() => useOffline()); + + act(() => { + const messageEvent = { + data: { + type: MESSAGE_TYPE.OFFLINE, + value: false, + message: 'Online', + }, + }; + + mockAddEventListener.mock.calls[0][1](messageEvent); + }); + + expect(result.current.isOffline).toBe(false); + }); +}); diff --git a/src/frontend/apps/impress/src/features/service-worker/__tests__/useSWRegister.test.tsx b/src/frontend/apps/impress/src/features/service-worker/__tests__/useSWRegister.test.tsx index 1698907cd..cf168d8da 100644 --- a/src/frontend/apps/impress/src/features/service-worker/__tests__/useSWRegister.test.tsx +++ b/src/frontend/apps/impress/src/features/service-worker/__tests__/useSWRegister.test.tsx @@ -26,6 +26,7 @@ describe('useSWRegister', () => { value: { register: registerSpy, addEventListener: jest.fn(), + removeEventListener: jest.fn(), }, writable: true, }); diff --git a/src/frontend/apps/impress/src/features/service-worker/conf.ts b/src/frontend/apps/impress/src/features/service-worker/conf.ts index fe4ba15e4..14ec6d88a 100644 --- a/src/frontend/apps/impress/src/features/service-worker/conf.ts +++ b/src/frontend/apps/impress/src/features/service-worker/conf.ts @@ -3,14 +3,15 @@ import pkg from '@/../package.json'; export const SW_DEV_URL = [ 'http://localhost:3000', 'https://impress.127.0.0.1.nip.io', - 'https://impress-staging.beta.numerique.gouv.fr', ]; export const SW_DEV_API = 'http://localhost:8071'; - export const SW_VERSION = `v-${process.env.NEXT_PUBLIC_BUILD_ID}`; - export const DAYS_EXP = 5; export const getCacheNameVersion = (cacheName: string) => `${pkg.name}-${cacheName}-${SW_VERSION}`; + +export const MESSAGE_TYPE = { + OFFLINE: 'OFFLINE', +}; diff --git a/src/frontend/apps/impress/src/features/service-worker/hooks/useOffline.tsx b/src/frontend/apps/impress/src/features/service-worker/hooks/useOffline.tsx new file mode 100644 index 000000000..3dd499422 --- /dev/null +++ b/src/frontend/apps/impress/src/features/service-worker/hooks/useOffline.tsx @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; +import { create } from 'zustand'; + +import { MESSAGE_TYPE } from '../conf'; + +interface OfflineMessageData { + type: string; + value: boolean; + message: string; +} + +interface IsOfflineState { + isOffline: boolean; + setIsOffline: (value: boolean) => void; +} + +export const useIsOffline = create((set) => ({ + isOffline: typeof navigator !== 'undefined' && !navigator.onLine, + setIsOffline: (value: boolean) => set({ isOffline: value }), +})); + +export const useOffline = () => { + const { setIsOffline } = useIsOffline(); + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.data?.type === MESSAGE_TYPE.OFFLINE) { + setIsOffline(event.data.value); + } + }; + + navigator.serviceWorker?.addEventListener('message', handleMessage); + + return () => { + navigator.serviceWorker?.removeEventListener('message', handleMessage); + }; + }, [setIsOffline]); +}; diff --git a/src/frontend/apps/impress/src/features/service-worker/hooks/useSWRegister.tsx b/src/frontend/apps/impress/src/features/service-worker/hooks/useSWRegister.tsx index da897cff5..5021862de 100644 --- a/src/frontend/apps/impress/src/features/service-worker/hooks/useSWRegister.tsx +++ b/src/frontend/apps/impress/src/features/service-worker/hooks/useSWRegister.tsx @@ -30,11 +30,22 @@ export const useSWRegister = () => { }); const currentController = navigator.serviceWorker.controller; - navigator.serviceWorker.addEventListener('controllerchange', () => { + const onControllerChange = () => { if (currentController) { window.location.reload(); } - }); + }; + navigator.serviceWorker.addEventListener( + 'controllerchange', + onControllerChange, + ); + + return () => { + navigator.serviceWorker.removeEventListener( + 'controllerchange', + onControllerChange, + ); + }; } }, []); }; diff --git a/src/frontend/apps/impress/src/features/service-worker/index.ts b/src/frontend/apps/impress/src/features/service-worker/index.ts index 79604f23f..a05a7d1e2 100644 --- a/src/frontend/apps/impress/src/features/service-worker/index.ts +++ b/src/frontend/apps/impress/src/features/service-worker/index.ts @@ -1 +1,2 @@ +export * from './hooks/useOffline'; export * from './hooks/useSWRegister'; diff --git a/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts similarity index 98% rename from src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts rename to src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts index 63127f971..7a03f291d 100644 --- a/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts @@ -3,9 +3,9 @@ import { WorkboxPlugin } from 'workbox-core'; import { Doc, DocsResponse } from '@/docs/doc-management'; import { LinkReach, LinkRole } from '@/docs/doc-management/types'; -import { DBRequest, DocsDB } from './DocsDB'; -import { RequestSerializer } from './RequestSerializer'; -import { SyncManager } from './SyncManager'; +import { DBRequest, DocsDB } from '../DocsDB'; +import { RequestSerializer } from '../RequestSerializer'; +import { SyncManager } from '../SyncManager'; interface OptionsReadonly { tableName: 'doc-list' | 'doc-item'; diff --git a/src/frontend/apps/impress/src/features/service-worker/plugins/OfflinePlugin.ts b/src/frontend/apps/impress/src/features/service-worker/plugins/OfflinePlugin.ts new file mode 100644 index 000000000..455227e45 --- /dev/null +++ b/src/frontend/apps/impress/src/features/service-worker/plugins/OfflinePlugin.ts @@ -0,0 +1,36 @@ +import { WorkboxPlugin } from 'workbox-core'; + +import { MESSAGE_TYPE } from '../conf'; + +declare const self: ServiceWorkerGlobalScope; + +export class OfflinePlugin implements WorkboxPlugin { + constructor() {} + + postMessage = async (value: boolean, message: string) => { + const allClients = await self.clients.matchAll({ + includeUncontrolled: true, + }); + + for (const client of allClients) { + client.postMessage({ + type: MESSAGE_TYPE.OFFLINE, + value, + message, + }); + } + }; + + /** + * Means that the fetch failed (500 is not failed), so often it is a network error. + */ + fetchDidFail: WorkboxPlugin['fetchDidFail'] = async () => { + void this.postMessage(true, 'fetchDidFail'); + return Promise.resolve(); + }; + + fetchDidSucceed: WorkboxPlugin['fetchDidSucceed'] = async ({ response }) => { + void this.postMessage(false, 'fetchDidSucceed'); + return Promise.resolve(response); + }; +} diff --git a/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts b/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts index b4bf7b961..947b27370 100644 --- a/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts +++ b/src/frontend/apps/impress/src/features/service-worker/service-worker-api.ts @@ -3,10 +3,11 @@ import { ExpirationPlugin } from 'workbox-expiration'; import { registerRoute } from 'workbox-routing'; import { NetworkFirst, NetworkOnly } from 'workbox-strategies'; -import { ApiPlugin } from './ApiPlugin'; import { DocsDB } from './DocsDB'; import { SyncManager } from './SyncManager'; import { DAYS_EXP, SW_DEV_API, getCacheNameVersion } from './conf'; +import { ApiPlugin } from './plugins/ApiPlugin'; +import { OfflinePlugin } from './plugins/OfflinePlugin'; declare const self: ServiceWorkerGlobalScope; @@ -37,6 +38,7 @@ registerRoute( type: 'list', syncManager, }), + new OfflinePlugin(), ], }), 'GET', @@ -52,6 +54,7 @@ registerRoute( type: 'item', syncManager, }), + new OfflinePlugin(), ], }), 'GET', @@ -66,6 +69,7 @@ registerRoute( type: 'update', syncManager, }), + new OfflinePlugin(), ], }), 'PATCH', @@ -79,6 +83,7 @@ registerRoute( type: 'create', syncManager, }), + new OfflinePlugin(), ], }), 'POST', @@ -93,6 +98,7 @@ registerRoute( type: 'delete', syncManager, }), + new OfflinePlugin(), ], }), 'DELETE', @@ -111,6 +117,7 @@ registerRoute( type: 'synch', syncManager, }), + new OfflinePlugin(), ], }), 'GET', diff --git a/src/frontend/apps/impress/src/features/service-worker/service-worker.ts b/src/frontend/apps/impress/src/features/service-worker/service-worker.ts index aa8f28614..b4db83f6d 100644 --- a/src/frontend/apps/impress/src/features/service-worker/service-worker.ts +++ b/src/frontend/apps/impress/src/features/service-worker/service-worker.ts @@ -19,8 +19,9 @@ import { } from 'workbox-strategies'; // eslint-disable-next-line import/order -import { ApiPlugin } from './ApiPlugin'; import { DAYS_EXP, SW_DEV_URL, SW_VERSION, getCacheNameVersion } from './conf'; +import { ApiPlugin } from './plugins/ApiPlugin'; +import { OfflinePlugin } from './plugins/OfflinePlugin'; import { isApiUrl } from './service-worker-api'; // eslint-disable-next-line import/order @@ -154,6 +155,7 @@ registerRoute( plugins: [ new CacheableResponsePlugin({ statuses: [0, 200] }), new ExpirationPlugin({ maxAgeSeconds: 24 * 60 * 60 * DAYS_EXP }), + new OfflinePlugin(), ], }), ); @@ -170,6 +172,7 @@ registerRoute( new ExpirationPlugin({ maxAgeSeconds: 24 * 60 * 60 * DAYS_EXP, }), + new OfflinePlugin(), ], }), 'GET', @@ -236,6 +239,20 @@ registerRoute( }), ); +/** + * External urls post cache strategy + * It is interesting to intercept the request + * to have a fine grain control about if the user is + * online or offline + */ +registerRoute( + ({ url }) => !url.href.includes(self.location.origin) && !isApiUrl(url.href), + new NetworkOnly({ + plugins: [new OfflinePlugin()], + }), + 'POST', +); + /** * Cache all other files */ diff --git a/src/frontend/apps/impress/src/pages/_app.tsx b/src/frontend/apps/impress/src/pages/_app.tsx index 683341c28..73e41facd 100644 --- a/src/frontend/apps/impress/src/pages/_app.tsx +++ b/src/frontend/apps/impress/src/pages/_app.tsx @@ -3,7 +3,7 @@ import Head from 'next/head'; import { useTranslation } from 'react-i18next'; import { AppProvider } from '@/core/'; -import { useSWRegister } from '@/features/service-worker/'; +import { useOffline, useSWRegister } from '@/features/service-worker/'; import '@/i18n/initI18n'; import { NextPageWithLayout } from '@/types/next'; @@ -15,6 +15,7 @@ type AppPropsWithLayout = AppProps & { export default function App({ Component, pageProps }: AppPropsWithLayout) { useSWRegister(); + useOffline(); const getLayout = Component.getLayout ?? ((page) => page); const { t } = useTranslation(); diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index 74f2d0391..295672436 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -126,7 +126,7 @@ const DocPage = ({ id }: DocProps) => { causes={error.cause} icon={ error.status === 502 ? ( - + ) : undefined } />