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
}
/>