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 66aa639fc..60053a2f0 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -20,7 +20,6 @@ export const CONFIG = { API_USERS_SEARCH_QUERY_MIN_LENGTH: 3, COLLABORATION_WS_INACTIVITY_TIMEOUT: 15, COLLABORATION_WS_URL: process.env.COLLABORATION_WS_URL, - COLLABORATION_WS_NOT_CONNECTED_READ_ONLY: true, CONVERSION_UPLOAD_ENABLED: true, CONVERSION_FILE_EXTENSIONS_ALLOWED: ['.docx', '.md'], CONVERSION_FILE_MAX_SIZE: 20971520, 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 c5ef93bb5..be5d56224 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -49,7 +49,6 @@ export interface ConfigResponse { AI_FEATURE_LEGACY_ENABLED?: boolean; API_USERS_SEARCH_QUERY_MIN_LENGTH?: number; COLLABORATION_WS_URL?: string; - COLLABORATION_WS_NOT_CONNECTED_READ_ONLY?: boolean; COLLABORATION_WS_INACTIVITY_TIMEOUT?: number | null; CONVERSION_FILE_EXTENSIONS_ALLOWED: string[]; CONVERSION_FILE_MAX_SIZE: number; 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 25f0f650c..c8df47a7c 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 @@ -50,7 +50,6 @@ import { useAnalytics } from '@/libs/Analytics'; import { AI_FEATURE_FLAG, DEFAULT_LOCALE } from '../conf'; import { useHeadings, - useSaveDoc, useScrollToBlockAnchor, useShortcuts, useUploadFile, @@ -109,7 +108,6 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { const { setEditor } = useEditorStore(); const { themeTokens } = useCunninghamTheme(); const refEditorContainer = useRef(null); - useSaveDoc(doc.id, provider.doc); const { i18n, t } = useTranslation(); const langLocalesBN = diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx deleted file mode 100644 index 8b055d9a6..000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; -import fetchMock from 'fetch-mock'; -import { useRouter } from 'next/router'; -import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as Y from 'yjs'; - -import { AppWrapper } from '@/tests/utils'; - -import { useSaveDoc } from '../useSaveDoc'; - -vi.mock('next/router', () => ({ - useRouter: vi.fn(), -})); - -vi.mock('@/docs/doc-versioning', () => ({ - KEY_LIST_DOC_VERSIONS: 'test-key-list-doc-versions', -})); - -vi.mock('@/docs/doc-management', async () => ({ - useUpdateDoc: ( - await vi.importActual('@/docs/doc-management/api/useUpdateDoc') - ).useUpdateDoc, -})); - -describe('useSaveDoc', () => { - const mockRouterEvents = { - on: vi.fn(), - off: vi.fn(), - }; - - beforeEach(() => { - vi.clearAllMocks(); - fetchMock.hardReset(); - fetchMock.mockGlobal(); - - (useRouter as Mock).mockReturnValue({ - events: mockRouterEvents, - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should setup event listeners on mount', () => { - const yDoc = new Y.Doc(); - const docId = 'test-doc-id'; - - const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); - - renderHook(() => useSaveDoc(docId, yDoc), { - wrapper: AppWrapper, - }); - - // Verify router event listeners are set up - expect(mockRouterEvents.on).toHaveBeenCalledWith( - 'routeChangeStart', - expect.any(Function), - ); - - // Verify window event listener is set up - expect(addEventListenerSpy).toHaveBeenCalledWith( - 'beforeunload', - expect.any(Function), - ); - - addEventListenerSpy.mockRestore(); - }); - - it('should save when there are local changes', async () => { - vi.useFakeTimers(); - const yDoc = new Y.Doc(); - const docId = self.crypto.randomUUID(); - - fetchMock.patch(`http://test.jest/api/v1.0/documents/${docId}/content/`, { - body: JSON.stringify({ - id: docId, - content: 'test-content', - }), - }); - - renderHook(() => useSaveDoc(docId, yDoc), { - wrapper: AppWrapper, - }); - - act(() => { - // Trigger a local update - yDoc.getMap('test').set('key', 'value'); - }); - - act(() => { - // Advance timers to trigger the save interval - vi.advanceTimersByTime(61000); - }); - - // Switch to real timers to allow the mutation promise to resolve - vi.useRealTimers(); - - await waitFor(() => { - expect(fetchMock.callHistory.lastCall()?.url).toBe( - `http://test.jest/api/v1.0/documents/${docId}/content/`, - ); - }); - }); - - it('should not save when there are no local changes', () => { - vi.useFakeTimers(); - const yDoc = new Y.Doc(); - const docId = 'test-doc-id'; - - fetchMock.patch( - 'http://test.jest/api/v1.0/documents/test-doc-id/content/', - { - body: JSON.stringify({ - id: 'test-doc-id', - content: 'test-content', - }), - }, - ); - - renderHook(() => useSaveDoc(docId, yDoc), { - wrapper: AppWrapper, - }); - - act(() => { - // Advance timers without triggering any local updates - vi.advanceTimersByTime(61000); - }); - - // Since there are no local changes, no API call should be made - expect(fetchMock.callHistory.calls().length).toBe(0); - - vi.useRealTimers(); - }); - - const setupSavedDoc = async (yDoc: Y.Doc, docId: string) => { - fetchMock.patch(`http://test.jest/api/v1.0/documents/${docId}/content/`, { - body: JSON.stringify({ id: docId, content: 'test-content' }), - }); - - renderHook(() => useSaveDoc(docId, yDoc), { - wrapper: AppWrapper, - }); - - act(() => { - // Trigger a local update so there is something to save - yDoc.getMap('test').set('key', 'value'); - }); - }; - - const dispatchBeforeUnload = () => { - const event = new Event('beforeunload', { cancelable: true }); - act(() => { - window.dispatchEvent(event); - }); - return event; - }; - - it('should save with keepalive when the page is unloading', async () => { - const yDoc = new Y.Doc(); - const docId = self.crypto.randomUUID(); - - await setupSavedDoc(yDoc, docId); - - const event = dispatchBeforeUnload(); - - await waitFor(() => { - expect(fetchMock.callHistory.lastCall()?.url).toBe( - `http://test.jest/api/v1.0/documents/${docId}/content/`, - ); - }); - - expect(fetchMock.callHistory.lastCall()?.options.keepalive).toBe(true); - // The browser owns the request, no need to hold the unload back - expect(event.defaultPrevented).toBe(false); - }); - - it('should not use keepalive when saving without unloading', async () => { - vi.useFakeTimers(); - const yDoc = new Y.Doc(); - const docId = self.crypto.randomUUID(); - - await setupSavedDoc(yDoc, docId); - - act(() => { - vi.advanceTimersByTime(61000); - }); - - vi.useRealTimers(); - - await waitFor(() => { - expect(fetchMock.callHistory.lastCall()?.url).toBe( - `http://test.jest/api/v1.0/documents/${docId}/content/`, - ); - }); - - expect(fetchMock.callHistory.lastCall()?.options.keepalive).toBeFalsy(); - }); - - it('should hold the unload back when the doc is too big for keepalive with firefox', async () => { - const yDoc = new Y.Doc(); - const docId = self.crypto.randomUUID(); - - // Mock Firefox user agent to simulate Firefox behavior - vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/117.0', - ); - - fetchMock.patch(`http://test.jest/api/v1.0/documents/${docId}/content/`, { - body: JSON.stringify({ id: docId, content: 'test-content' }), - }); - - renderHook(() => useSaveDoc(docId, yDoc), { - wrapper: AppWrapper, - }); - - act(() => { - // Over the 64 KiB keepalive cap once base64 encoded - yDoc.getText('big').insert(0, 'a'.repeat(70 * 1024)); - }); - - const event = dispatchBeforeUnload(); - - await waitFor(() => { - expect(fetchMock.callHistory.lastCall()?.url).toBe( - `http://test.jest/api/v1.0/documents/${docId}/content/`, - ); - }); - - expect(fetchMock.callHistory.lastCall()?.options.keepalive).toBe(false); - // Regular fetch: the unload is held back so the request has time to go out - expect(event.defaultPrevented).toBe(true); - }); - - it('should cleanup event listeners on unmount', () => { - const yDoc = new Y.Doc(); - const docId = 'test-doc-id'; - const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); - - const { unmount } = renderHook(() => useSaveDoc(docId, yDoc), { - wrapper: AppWrapper, - }); - - unmount(); - - // Verify router event listeners are cleaned up - expect(mockRouterEvents.off).toHaveBeenCalledWith( - 'routeChangeStart', - expect.any(Function), - ); - - // Verify window event listener is cleaned up - expect(removeEventListenerSpy).toHaveBeenCalledWith( - 'beforeunload', - expect.any(Function), - ); - }); -}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts index d647518a5..a168772d8 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts @@ -1,5 +1,4 @@ export * from './useHeadings'; -export * from './useSaveDoc'; export * from './useScrollToBlockAnchor'; export * from './useShortcuts'; export * from './useUploadFile'; 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 150b7c07b..eb26a0cb9 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 @@ -3,10 +3,6 @@ import { useEffect } from 'react'; import { useCollaborationUrl, useConfig } from '@/core/config'; import { KEY_DOC } from '@/docs/doc-management/api/useDoc'; -import { - KEY_DOC_CONTENT, - useDocContent, -} from '@/docs/doc-management/api/useDocContent'; import { useProviderStore } from '@/docs/doc-management/stores/useProviderStore'; import { useIsOffline } from '@/features/service-worker/hooks/useOffline'; import { useBroadcastStore } from '@/stores/useBroadcastStore'; @@ -33,13 +29,6 @@ export const useCollaboration = (room: string) => { resumeFromInactivity, } = useProviderStore(); const isOffline = useIsOffline((state) => state.isOffline); - const { data: docContent } = useDocContent( - { id: room }, - { - staleTime: 30000, // 30 seconds - We keep the data fresh as it is a highly collaborative page - queryKey: [KEY_DOC_CONTENT, { id: room }], - }, - ); /** * When offline, the WebSocket never connects so the provider would stay @@ -89,20 +78,13 @@ export const useCollaboration = (room: string) => { * Set the provider when the collaboration URL and the document content are available. */ useEffect(() => { - if (!room || !collaborationUrl || provider || docContent === undefined) { + if (!room || !collaborationUrl || provider) { return; } - const newProvider = createProvider(collaborationUrl, room, docContent); + const newProvider = createProvider(collaborationUrl, room); setBroadcastProvider(newProvider); - }, [ - provider, - collaborationUrl, - createProvider, - docContent, - room, - setBroadcastProvider, - ]); + }, [provider, collaborationUrl, createProvider, room, setBroadcastProvider]); /** * Destroy the provider when the component is unmounted diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx deleted file mode 100644 index 726b19913..000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useRouter } from 'next/router'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { WebsocketProvider } from 'y-websocket'; -import * as Y from 'yjs'; - -import { - canKeepaliveContent, - useDocContentUpdate, -} from '@/docs/doc-management/api/useDocContentUpdate'; -import { useProviderStore } from '@/docs/doc-management/stores/useProviderStore'; -import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning/api/useDocVersions'; -import { COMMENT_UPDATE_ORIGIN } from '@/features/docs/doc-comments/api/DocsThreadStore'; -import { useIsOffline } from '@/features/service-worker'; -import { toBase64 } from '@/utils/string'; -import { isFirefox } from '@/utils/userAgent'; - -const SAVE_INTERVAL = 60000; - -export const useSaveDoc = (docId: string, yDoc: Y.Doc) => { - /** - * isSynced is more reliable than isConnected in this cases - * because it indicates that the content is fully synchronised - * with the yjs server - */ - const { isSynced: isConnectedToCollabServer } = useProviderStore(); - - const { isOffline } = useIsOffline(); - const isSavingRef = useRef(false); - const { mutate: updateDocContent } = useDocContentUpdate({ - listInvalidQueries: [KEY_LIST_DOC_VERSIONS], - isOptimistic: isOffline, // Enable optimistic updates when offline, to update the cache immediately - onSuccess: () => { - isSavingRef.current = false; - setIsLocalChange(false); - }, - onError: () => { - isSavingRef.current = false; - }, - }); - const [isLocalChange, setIsLocalChange] = useState(false); - - /** - * Update initial doc when doc is updated by other users, - * so only the user typing will trigger the save. - * This is to avoid saving the same doc multiple time. - */ - useEffect(() => { - const onUpdate = ( - _uintArray: Uint8Array, - _pluginKey: string, - _updatedDoc: Y.Doc, - transaction: Y.Transaction, - ) => { - /** - * When the AI edit the doc transaction.local is false, - * so we check the transaction origin to know where - * the transaction comes from. - * "PluginKey" origin comes from the current user, but transaction.local is more reliable - * Updates from other users are applied by the collaboration server with - * the provider instance as origin, it seems quite reliable too. - * The AI origin seems to not be reliable enough, but by deduction if it's not local - * and not from other users, it has to be from the AI. - * - * TODO: see if we can get the local changes from the AI - */ - const isAIChange = - !transaction.local && - !(transaction.origin instanceof WebsocketProvider); - - /** - * notifySubscribers generate a transaction that can be - * interpreted as a local change. - * We intercept the update with this origin to - * avoid marking the change as local. - */ - if (transaction.origin === COMMENT_UPDATE_ORIGIN) { - return; - } - - setIsLocalChange(transaction.local || isAIChange); - }; - - yDoc.on('update', onUpdate); - - return () => { - yDoc.off('update', onUpdate); - }; - }, [yDoc]); - - /** - * `isSaving` tells whether a request was actually sent, `isKeptAlive` - * whether it was handed over to the browser process (see `keepalive`) and - * will therefore outlive the page. - */ - const saveDoc = useCallback( - ({ isUnloading = false }: { isUnloading?: boolean } = {}) => { - if (!isLocalChange || isSavingRef.current) { - return { isSaving: false, isKeptAlive: false }; - } - - isSavingRef.current = true; - const content = toBase64(Y.encodeStateAsUpdate(yDoc)); - const websocket = isConnectedToCollabServer; - updateDocContent({ - id: docId, - content, - websocket, - keepalive: isUnloading, - }); - - return { - isSaving: true, - isKeptAlive: isUnloading && canKeepaliveContent({ content, websocket }), - }; - }, - [isLocalChange, updateDocContent, docId, yDoc, isConnectedToCollabServer], - ); - - const router = useRouter(); - - useEffect(() => { - const onSave = (e?: Event) => { - const isUnloading = typeof e !== 'undefined' && e.type === 'beforeunload'; - const { isSaving, isKeptAlive } = saveDoc({ isUnloading }); - - /** - * Firefox does not trigger the request every time the user leaves the page. - * Plus the request is not intercepted by the service worker. - * So we prevent the default behavior to have the popup asking the user - * if he wants to leave the page, by adding the popup, we let the time to the - * request to be sent, and intercepted by the service worker (for the offline part). - * - * We do the same for documents too big to be sent with `keepalive`: the - * request is a regular fetch, so it dies with the page unless we hold - * the unload back. - */ - if ( - isSaving && - isUnloading && - e.preventDefault && - isFirefox() && - !isKeptAlive - ) { - e.preventDefault(); - } - }; - - // Save every minute - const timeout = setInterval(() => onSave(), SAVE_INTERVAL); - // Save when the user leaves the page - addEventListener('beforeunload', onSave); - // Save when the user navigates to another page - router.events.on('routeChangeStart', onSave); - - return () => { - clearInterval(timeout); - - removeEventListener('beforeunload', onSave); - router.events.off('routeChangeStart', onSave); - }; - }, [router.events, saveDoc]); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocContent.tsx deleted file mode 100644 index 8b9882a6e..000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocContent.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { UseQueryOptions, useQuery } from '@tanstack/react-query'; -import { validate as uuidValidate } from 'uuid'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; - -export type DocContentParams = { - id: string; -}; - -export const getDocContent = async ({ - id, -}: DocContentParams): Promise => { - if (!uuidValidate(id)) { - throw new Error(`Invalid doc id in getDocContent: ${id}`); - } - - const response = await fetchAPI(`documents/${id}/content/`, { - headers: { - accept: 'text/plain,application/json', - }, - }); - - if (!response.ok) { - throw new APIError('Failed to get the doc', await errorCauses(response)); - } - - return response.text(); -}; - -export const KEY_DOC_CONTENT = 'doc-content'; - -export function useDocContent( - param: DocContentParams, - queryConfig?: UseQueryOptions, -) { - return useQuery({ - queryKey: queryConfig?.queryKey ?? [KEY_DOC_CONTENT, param], - queryFn: () => getDocContent(param), - ...queryConfig, - }); -} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocContentUpdate.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocContentUpdate.tsx deleted file mode 100644 index be986e857..000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocContentUpdate.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { - UseMutationOptions, - useMutation, - useQueryClient, -} from '@tanstack/react-query'; -import { validate as uuidValidate } from 'uuid'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; - -import { Doc } from '../types'; - -import { KEY_DOC_CONTENT } from './useDocContent'; - -export interface UpdateDocContentParams { - id: Doc['id']; - content: string; // Base64 encoded content - websocket?: boolean; - /** - * Hand the request over to the browser process so it survives the page - * being torn down. Needed when saving from "beforeunload": a regular fetch - * is aborted with the document, and the server never sees the request. - */ - keepalive?: boolean; -} - -/** - * The fetch spec caps the body of a keepalive request. Over that limit the - * browser rejects the request outright, so we fall back to a regular fetch. - */ -const KEEPALIVE_MAX_BODY_SIZE = 64 * 1024; - -const buildContentBody = ({ - content, - websocket, -}: Pick) => - JSON.stringify({ - content, - websocket, - }); - -/** Whether the doc is small enough to be saved with a keepalive request. */ -export const canKeepaliveContent = ( - params: Pick, -) => buildContentBody(params).length <= KEEPALIVE_MAX_BODY_SIZE; - -export const updateDocContent = async ({ - id, - content, - websocket, - keepalive, -}: UpdateDocContentParams): Promise => { - if (!uuidValidate(id)) { - throw new Error(`Invalid doc id in updateDocContent: ${id}`); - } - - const body = buildContentBody({ content, websocket }); - - const response = await fetchAPI(`documents/${id}/content/`, { - method: 'PATCH', - body, - keepalive: keepalive && body.length <= KEEPALIVE_MAX_BODY_SIZE, - }); - - if (!response.ok) { - throw new APIError( - 'Failed to update the doc content', - await errorCauses(response), - ); - } -}; - -type UseDocContentUpdate = UseMutationOptions< - void, - APIError, - UpdateDocContentParams -> & { - isOptimistic?: boolean; - listInvalidQueries?: string[]; -}; - -export function useDocContentUpdate(queryConfig?: UseDocContentUpdate) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: updateDocContent, - ...queryConfig, - onMutate: (variables) => { - /** - * If optimistic, we update the content cache immediately with the new content - * It is useful when we are in offline mode because the onSuccess is not always triggered. - */ - if (queryConfig?.isOptimistic) { - const previousContent = queryClient.getQueryData([ - KEY_DOC_CONTENT, - { id: variables.id }, - ]); - - queryClient.setQueryData( - [KEY_DOC_CONTENT, { id: variables.id }], - variables.content, - ); - - return { previousContent }; - } - }, - onSuccess: (data, variables, onMutateResult, context) => { - if (!queryConfig?.isOptimistic) { - /** - * If not optimistic, we need to update the content cache with the new content returned - * from the server - */ - queryClient.setQueryData( - [KEY_DOC_CONTENT, { id: variables.id }], - variables.content, - ); - } - - queryConfig?.listInvalidQueries?.forEach((queryKey) => { - void queryClient.resetQueries({ - queryKey: [queryKey], - }); - }); - - if (queryConfig?.onSuccess) { - void queryConfig.onSuccess(data, variables, onMutateResult, context); - } - }, - onError: (error, variables, onMutateResult, context) => { - if ( - queryConfig?.isOptimistic && - (onMutateResult as { previousContent: unknown })?.previousContent - ) { - const previousContent = (onMutateResult as { previousContent: unknown }) - .previousContent; - - queryClient.setQueryData( - [KEY_DOC_CONTENT, { id: variables.id }], - previousContent, - ); - } - - if (queryConfig?.onError) { - queryConfig.onError(error, variables, onMutateResult, context); - } - }, - }); -} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx index 9a8afbc43..9ef8530f4 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDuplicateDoc.tsx @@ -5,16 +5,11 @@ import { useQueryClient, } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import * as Y from 'yjs'; import { APIError, errorCauses, fetchAPI } from '@/api'; -import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning/api/useDocVersions'; -import { toBase64 } from '@/utils/string'; -import { useProviderStore } from '../stores'; import { Doc } from '../types'; -import { useDocContentUpdate } from './useDocContentUpdate'; import { KEY_LIST_DOC } from './useDocs'; interface DuplicateDocPayload { @@ -57,27 +52,10 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) { const queryClient = useQueryClient(); const { toast } = useToastProvider(); const { t } = useTranslation(); - const { provider } = useProviderStore(); - - const { mutateAsync: updateDocContent } = useDocContentUpdate({ - listInvalidQueries: [KEY_LIST_DOC_VERSIONS], - }); return useMutation({ - mutationFn: async (variables) => { - // Save the document if we can first, to ensure the latest state is duplicated - const canSave = - variables.canSave && provider && provider.doc.guid === variables.docId; - - if (canSave) { - await updateDocContent({ - id: variables.docId, - content: toBase64(Y.encodeStateAsUpdate(provider.doc)), - }); - } - - return await duplicateDoc(variables); - }, + // TODO(yhub): double check the saving is made correctly from the back so + mutationFn: duplicateDoc, onSuccess: (data, variables, onMutateResult, context) => { void queryClient.resetQueries({ queryKey: [KEY_LIST_DOC], diff --git a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalConfirmationVersion.tsx b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalConfirmationVersion.tsx index 7ee3c8008..dfcf317e3 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalConfirmationVersion.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-versioning/components/ModalConfirmationVersion.tsx @@ -1,22 +1,12 @@ -import { - Button, - Modal, - ModalSize, - VariantType, - useToastProvider, -} from '@gouvfr-lasuite/ui-components'; +import { Button, Modal, ModalSize } from '@gouvfr-lasuite/ui-components'; import { useTranslation } from 'react-i18next'; import { createGlobalStyle } from 'styled-components'; import { Box, Text } from '@/components'; -import { useThreadStore } from '@/docs/doc-comments/stores/useThreadStore'; -import { Doc, base64ToYDoc, useProviderStore } from '@/docs/doc-management/'; -import { useDocContentUpdate } from '@/docs/doc-management/api/useDocContentUpdate'; +import { Doc } from '@/docs/doc-management/'; import { useDocVersion } from '../api'; -import { KEY_LIST_DOC_VERSIONS } from '../api/useDocVersions'; import { Versions } from '../types'; -import { revertUpdate } from '../utils'; const ModalStyle = createGlobalStyle` .c__modal__title { @@ -33,7 +23,7 @@ interface ModalConfirmationVersionProps { export const ModalConfirmationVersion = ({ onClose, - onSuccess, + onSuccess: __onSuccess, docId, versionId, }: ModalConfirmationVersionProps) => { @@ -42,29 +32,28 @@ export const ModalConfirmationVersion = ({ versionId, }); const { t } = useTranslation(); - const { toast } = useToastProvider(); - const { provider } = useProviderStore(); - const { threadStore } = useThreadStore(); - const { mutate: updateDocContent } = useDocContentUpdate({ - listInvalidQueries: [KEY_LIST_DOC_VERSIONS], - onSuccess: () => { - const onDisplaySuccess = () => { - toast(t('Version restored successfully'), VariantType.SUCCESS); - onSuccess(); - }; - if (!provider || !version?.content) { - onDisplaySuccess(); - return; - } + // TODO(yhub) : Revert the doc to a previous state using Y.js / Yhub + // const { mutate: updateDocContent } = useDocContentUpdate({ + // listInvalidQueries: [KEY_LIST_DOC_VERSIONS], + // onSuccess: () => { + // const onDisplaySuccess = () => { + // toast(t('Version restored successfully'), VariantType.SUCCESS); + // onSuccess(); + // }; - revertUpdate(provider.doc, provider.doc, base64ToYDoc(version.content)); + // if (!provider || !version?.content) { + // onDisplaySuccess(); + // return; + // } - threadStore?.refreshThreads(); + // revertUpdate(provider.doc, provider.doc, base64ToYDoc(version.content)); - onDisplaySuccess(); - }, - }); + // threadStore?.refreshThreads(); + + // onDisplaySuccess(); + // }, + // }); if (!version) { return null; @@ -96,11 +85,6 @@ export const ModalConfirmationVersion = ({ return; } - updateDocContent({ - id: docId, - content: version.content, - }); - onClose(); }} > diff --git a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts index e381acddc..500e144ef 100644 --- a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts @@ -2,19 +2,20 @@ import { WorkboxPlugin } from 'workbox-core'; import { Doc, DocsResponse } from '@/docs/doc-management'; import { LinkReach, LinkRole, Role } from '@/docs/doc-management/types'; -import { UpdateDocContentParams } from '@/features/docs/doc-management/api/useDocContentUpdate'; import { DBRequest, DocsDB } from '../DocsDB'; import { RequestSerializer } from '../RequestSerializer'; import { SyncManager } from '../SyncManager'; interface OptionsReadonly { - tableName: 'doc-list' | 'doc-item' | 'doc-content'; - type: 'list' | 'item' | 'content'; + tableName: 'doc-list' | 'doc-item'; + type: 'list' | 'item'; } +// TODO(yhub): Used to work offline, we need to implement the patch mechanism +// It will be probably linked to the HTTP fallback mechanism of yhub interface OptionsMutate { - type: 'update' | 'delete' | 'create' | 'content-update'; + type: 'update' | 'delete' | 'create'; } interface OptionsSync { @@ -53,27 +54,6 @@ export class ApiPlugin implements WorkboxPlugin { response, }) => { try { - // For content requests, a 304 means the document hasn't changed: - // transparently serve the cached version from IDB. - if (this.options.type === 'content' && response.status === 304) { - const db = await DocsDB.open(); - const entry = await db.get('doc-content', request.url); - db.close(); - if (entry) { - return new Response(entry.content, { - status: 200, - statusText: 'OK', - headers: { - 'Content-Type': 'text/plain', - ...(entry.etag && { ETag: entry.etag }), - ...(entry.lastModified && { - 'Last-Modified': entry.lastModified, - }), - }, - }); - } - } - if (response.status !== 200) { return response; } @@ -82,17 +62,6 @@ export class ApiPlugin implements WorkboxPlugin { const tableName = this.options.tableName; const body = (await response.clone().json()) as DocsResponse | Doc; await DocsDB.cacheResponse(request.url, body, tableName); - } else if (this.options.type === 'content') { - // Cache the content response with its ETag / Last-Modified to be - // able to use it for conditional requests and offline access. - const content = await response.clone().text(); - const etag = response.headers.get('ETag') ?? ''; - const lastModified = response.headers.get('Last-Modified') ?? ''; - await DocsDB.cacheResponse( - request.url, - { etag, lastModified, content }, - 'doc-content', - ); } else if (this.options.type === 'update') { const db = await DocsDB.open(); const storedResponse = await db.get('doc-item', request.url); @@ -135,7 +104,6 @@ export class ApiPlugin implements WorkboxPlugin { requestWillFetch: WorkboxPlugin['requestWillFetch'] = async ({ request }) => { if ( this.options.type === 'update' || - this.options.type === 'content-update' || this.options.type === 'create' || this.options.type === 'delete' ) { @@ -144,27 +112,6 @@ export class ApiPlugin implements WorkboxPlugin { await this.options.syncManager.sync(); - // For content requests, add If-None-Match / If-Modified-Since from IDB - // so the backend can return a 304 when the document hasn't changed. - if (this.options.type === 'content') { - try { - const db = await DocsDB.open(); - const entry = await db.get('doc-content', request.url); - db.close(); - if (entry?.etag || entry?.lastModified) { - const headers = new Headers(request.headers); - if (entry.etag) { - headers.set('If-None-Match', entry.etag); - } else { - headers.set('If-Modified-Since', entry.lastModified); - } - return new Request(request, { headers }); - } - } catch (error) { - console.error('SW: ApiPlugin requestWillFetch content error', error); - } - } - return Promise.resolve(request); }; @@ -188,13 +135,9 @@ export class ApiPlugin implements WorkboxPlugin { return this.handlerDidErrorDelete(request); case 'update': return this.handlerDidErrorUpdate(request); - case 'content-update': - return this.handlerDidErrorContentUpdate(request); case 'list': case 'item': return this.handlerDidErrorRead(this.options.tableName, request.url); - case 'content': - return this.handlerDidErrorContent(request); } return Promise.resolve(ApiPlugin.getApiCatchHandler()); @@ -492,56 +435,4 @@ export class ApiPlugin implements WorkboxPlugin { }, }); }; - - private handlerDidErrorContent = async (request: Request) => { - const db = await DocsDB.open(); - const entry = await db.get('doc-content', request.url); - db.close(); - - if (!entry) { - return Promise.resolve(ApiPlugin.getApiCatchHandler()); - } - - return new Response(entry.content, { - status: 200, - statusText: 'OK', - headers: { - 'Content-Type': 'text/plain', - ...(entry.etag && { ETag: entry.etag }), - ...(entry.lastModified && { 'Last-Modified': entry.lastModified }), - }, - }); - }; - - /** - * When the content update fails, we save the new content in the cache, and we will sync it later with the SyncManager. - * We return a 204 to the client to say that the update is successful, and we update the content in the cache so the - * client can see the new content while offline. - */ - private handlerDidErrorContentUpdate = async (request: Request) => { - const db = await DocsDB.open(); - const entry = await db.get('doc-content', request.url); - db.close(); - - if (!entry || !this.initialRequest) { - return new Response('Not found', { status: 404 }); - } - - await this.queueMutation(this.initialRequest); - - const bodyMutate = (await this.initialRequest - .clone() - .json()) as Partial; - const newContent = bodyMutate.content ?? entry.content; - await DocsDB.cacheResponse( - request.url, - { etag: '', lastModified: '', content: newContent }, - 'doc-content', - ); - - return new Response(null, { - status: 204, - statusText: 'No Content', - }); - }; } 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 1de19733e..80c8be8b6 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 @@ -62,42 +62,6 @@ registerRoute( 'GET', ); -registerRoute( - ({ url }) => - isApiUrl(url.href) && /\/documents\/[a-z0-9-]+\/content\/$/.test(url.href), - new NetworkOnly({ - plugins: [ - new ApiPlugin({ - tableName: 'doc-content', - type: 'content', - syncManager, - }), - new OfflinePlugin(), - ], - }), - 'GET', -); - -/** - * Mutate routes for the content update - * It will save in cache the request if the content update fails, and will retry - * to sync it later with the SyncManager - */ -registerRoute( - ({ url }) => - isApiUrl(url.href) && /\/documents\/[a-z0-9-]+\/content\/$/.test(url.href), - new NetworkOnly({ - plugins: [ - new ApiPlugin({ - type: 'content-update', - syncManager, - }), - new OfflinePlugin(), - ], - }), - 'PATCH', -); - /** * Mutate routes for the document update * It will save in cache the request if the document update fails, and will retry