From 405f9898ef131adbe3a4f23de32c582e03065e20 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Thu, 28 May 2026 10:14:22 +0530 Subject: [PATCH 1/5] document service: Update trash retrieval and local metadata handling. Previously, getCloudDocument fetched document details by default without specifying includeTrashed parameter, and restoring a document did not correctly clear the local indexedDB database's metadata columns for trash state (deletedAt and purgeAt) if the document existed locally. Now, modify getCloudDocument to accept includeTrashed as a parameter (which defaults to true), appending the query string when appropriate. In restoreCloudDocumentFromTrash, attempt to update local metadata if the document exists in the local database. Also, clean up unused index definitions in Flyway V4 schema migration. --- .../db/migration/V4__document_trash.sql | 7 ------ web/services/document.service.ts | 25 +++++++++++++++---- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/api/src/main/resources/db/migration/V4__document_trash.sql b/api/src/main/resources/db/migration/V4__document_trash.sql index aff9a83..07356b6 100644 --- a/api/src/main/resources/db/migration/V4__document_trash.sql +++ b/api/src/main/resources/db/migration/V4__document_trash.sql @@ -1,13 +1,6 @@ ALTER TABLE documents ADD COLUMN deleted_at TIMESTAMPTZ NULL; -DROP INDEX IF EXISTS uq_documents_user_source_local; -DROP INDEX IF EXISTS idx_documents_user_source_local; - -CREATE UNIQUE INDEX uq_documents_user_source_local - ON documents(user_id, source_local_id) - WHERE source_local_id IS NOT NULL AND deleted_at IS NULL; - CREATE INDEX idx_documents_trash_purge ON documents(deleted_at) WHERE deleted_at IS NOT NULL; diff --git a/web/services/document.service.ts b/web/services/document.service.ts index 8f0e7b3..3646164 100644 --- a/web/services/document.service.ts +++ b/web/services/document.service.ts @@ -233,11 +233,18 @@ class DocumentService { }; } - public async getCloudDocument(id: string, accessToken: string): Promise { - const body = await this.fetchApi(`/api/v1/documents/${encodeURIComponent(id)}`, { - method: 'GET', - accessToken, - }); + public async getCloudDocument( + id: string, + accessToken: string, + includeTrashed = true + ): Promise { + const body = await this.fetchApi( + `/api/v1/documents/${encodeURIComponent(id)}${includeTrashed ? '?includeTrashed=true' : ''}`, + { + method: 'GET', + accessToken, + } + ); const ydoc = body.yjsState ? decodeYjsState(this.base64ToUint8Array(body.yjsState)) @@ -543,6 +550,14 @@ class DocumentService { allowEmptyData: true, }); + try { + if (await this.documentExists(id)) { + await this.updateMetadata(id, { deletedAt: undefined, purgeAt: undefined }); + } + } catch (err) { + console.warn('Failed to update local metadata during restore:', err); + } + this.emitCloudDocumentsChanged(); } From f0dcda93045954e37d477982636bd1220dac784d Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Fri, 29 May 2026 15:42:09 +0530 Subject: [PATCH 2/5] document hook: Support restore operations and external sync. Previously, the useDocument hook did not expose a restore method to undelete a document, did not sync local state when a document was restored externally (e.g. from the sidebar), and did not restrict cloud metadata syncing when a document was in the trash. Now, add a restore callback to useDocument that calls the document service, clears the deletedAt / purgeAt metadata fields, and updates access level/cache to OWNER. Set up event listeners for documents changed events to automatically update the Redux state and access level to OWNER when an external restore happens. --- web/hooks/useDocument.hook.ts | 86 ++++++++++++-- .../unit/hooks/useDocument.hook.test.tsx | 112 ++++++++++++++++++ 2 files changed, 188 insertions(+), 10 deletions(-) diff --git a/web/hooks/useDocument.hook.ts b/web/hooks/useDocument.hook.ts index b9f55cc..0f510e0 100644 --- a/web/hooks/useDocument.hook.ts +++ b/web/hooks/useDocument.hook.ts @@ -177,7 +177,7 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { const id = documentId; const isSharedDocument = options?.isSharedDocument === true; const dispatch = useAppDispatch(); - const { meta, isLoading, error } = useAppSelector((state) => state.document); + const { currentDocumentId, meta, isLoading, error } = useAppSelector((state) => state.document); const { isAuthenticated, accessToken, user, isInitializing, refresh } = useAuth(); const { isOnline } = useNetworkStatus(); const accessTokenRef = useRef(accessToken); @@ -353,11 +353,13 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { } if (!cancelled) { + const isTrashedDoc = !!result.meta.deletedAt; if ( isAuthenticated && token && !isCloudReadInBackoff() && - !hasPendingSyncForRequestedDoc + !hasPendingSyncForRequestedDoc && + !isTrashedDoc ) { try { const myAccess = await documentService.getMyAccess(effectiveId, token); @@ -407,12 +409,14 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { } } else { setAccessLevel( - isAuthenticated - ? resolveAuthenticatedFallbackAccessLevel(effectiveId, { - currentAccessLevel: accessLevelRef.current, - isSharedDocument, - }) - : guestAccessLevel + isTrashedDoc + ? 'VIEW' + : isAuthenticated + ? resolveAuthenticatedFallbackAccessLevel(effectiveId, { + currentAccessLevel: accessLevelRef.current, + isSharedDocument, + }) + : guestAccessLevel ); } @@ -458,6 +462,7 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { // Clear stale ydoc immediately so the editor shows loading state setLocalYDoc(null); setResolvedDocumentId(id); + dispatch(clearDocument()); loadDoc(); return () => { @@ -636,7 +641,9 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { !accessToken || !isOnline || isCloudReadInBackoff() || - !resolvedDocumentId + !resolvedDocumentId || + resolvedDocumentId !== currentDocumentId || + !!meta?.deletedAt ) { return; } @@ -697,6 +704,8 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { accessToken, isOnline, resolvedDocumentId, + currentDocumentId, + meta, dispatch, isCloudReadInBackoff, refresh, @@ -801,17 +810,74 @@ export function useDocument(documentId: string, options?: UseDocumentOptions) { ] ); + // Listen for external restore events (e.g. from the sidebar) + useEffect(() => { + if (!resolvedDocumentId || !meta?.deletedAt) { + return; + } + + const handleDocsChanged = async () => { + try { + const localCopy = await documentService.loadDocument(resolvedDocumentId); + if (localCopy && !localCopy.meta.deletedAt) { + const updatedAt = new Date().toISOString(); + dispatch( + updateMetaAction({ + deletedAt: undefined, + purgeAt: undefined, + updatedAt, + }) + ); + // We don't need to check for the access level and directly set it + // to OWNER because only they have the option to restore the document. + setAccessLevel('OWNER'); + writeCachedDocumentAccessLevel(resolvedDocumentId, 'OWNER'); + } + } catch (err) { + console.warn('Failed to check document status on docs changed:', err); + } + }; + + window.addEventListener('cloud-documents-changed', handleDocsChanged); + window.addEventListener('local-documents-changed', handleDocsChanged); + + return () => { + window.removeEventListener('cloud-documents-changed', handleDocsChanged); + window.removeEventListener('local-documents-changed', handleDocsChanged); + }; + }, [resolvedDocumentId, meta?.deletedAt, dispatch]); + + const restore = useCallback(async () => { + if (!isAuthenticated || !accessToken || !resolvedDocumentId) { + return; + } + await documentService.restoreCloudDocumentFromTrash(resolvedDocumentId, accessToken); + + const updatedAt = new Date().toISOString(); + dispatch( + updateMetaAction({ + deletedAt: undefined, + purgeAt: undefined, + updatedAt, + }) + ); + + setAccessLevel('OWNER'); + writeCachedDocumentAccessLevel(resolvedDocumentId, 'OWNER'); + }, [isAuthenticated, accessToken, resolvedDocumentId, dispatch]); + return { documentId: resolvedDocumentId, ydoc, meta, accessLevel, - isReadOnly: isReadOnlyAccessLevel(accessLevel), + isReadOnly: isReadOnlyAccessLevel(accessLevel) || !!meta?.deletedAt, isRealtimeConnected, realtimeProvider, errorState, isLoading, error: error ? new Error(error) : null, updateMeta, + restore, }; } diff --git a/web/tests/unit/hooks/useDocument.hook.test.tsx b/web/tests/unit/hooks/useDocument.hook.test.tsx index 0baea73..23890bd 100644 --- a/web/tests/unit/hooks/useDocument.hook.test.tsx +++ b/web/tests/unit/hooks/useDocument.hook.test.tsx @@ -38,6 +38,7 @@ describe('useDocument', () => { let saveDocumentSpy: jest.SpyInstance; let updateMetadataSpy: jest.SpyInstance; let updateCloudMetadataSpy: jest.SpyInstance; + let restoreCloudDocumentFromTrashSpy: jest.SpyInstance; let dispatchEventSpy: jest.SpyInstance; beforeEach(() => { @@ -72,6 +73,9 @@ describe('useDocument', () => { updateCloudMetadataSpy = jest .spyOn(documentService, 'updateCloudMetadata') .mockImplementation(jest.fn()); + restoreCloudDocumentFromTrashSpy = jest + .spyOn(documentService, 'restoreCloudDocumentFromTrash') + .mockImplementation(jest.fn()); dispatchEventSpy = jest.spyOn(window, 'dispatchEvent'); (useAuth as jest.Mock).mockReturnValue({ isAuthenticated: false, @@ -96,6 +100,7 @@ describe('useDocument', () => { saveDocumentSpy.mockRestore(); updateMetadataSpy.mockRestore(); updateCloudMetadataSpy.mockRestore(); + restoreCloudDocumentFromTrashSpy.mockRestore(); dispatchEventSpy.mockRestore(); }); @@ -967,4 +972,111 @@ describe('useDocument', () => { expect(result.current.updateMeta).toBe(firstRef); }); + + it('should restore a document, update local Redux state and cache access level to OWNER', async () => { + const ydoc = new Y.Doc(); + const meta = { + title: 'Trashed Document', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + deletedAt: '2024-01-02T00:00:00.000Z', + }; + + (useAuth as jest.Mock).mockReturnValue({ + isAuthenticated: true, + accessToken: 'token-restore-test', + }); + + getCloudDocumentSpy.mockResolvedValue({ ydoc, meta }); + restoreCloudDocumentFromTrashSpy.mockResolvedValue(undefined); + getMyAccessSpy.mockResolvedValue({ + documentId: 'trashed-id', + allowed: true, + accessLevel: 'OWNER', + owner: true, + }); + + const store = createTestStore(); + function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + } + + const { result } = renderHook(() => useDocument('trashed-id'), { wrapper: Wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isReadOnly).toBe(true); + expect(result.current.meta?.deletedAt).toBe('2024-01-02T00:00:00.000Z'); + + await act(async () => { + await result.current.restore(); + }); + + expect(restoreCloudDocumentFromTrashSpy).toHaveBeenCalledWith( + 'trashed-id', + 'token-restore-test' + ); + + expect(result.current.isReadOnly).toBe(false); + expect(result.current.meta?.deletedAt).toBeUndefined(); + expect(result.current.accessLevel).toBe('OWNER'); + expect(store.getState().document.meta?.deletedAt).toBeUndefined(); + }); + + it('should detect external restore (e.g. from sidebar), update local Redux state and cache access level to OWNER', async () => { + const ydoc = new Y.Doc(); + const trashedMeta = { + title: 'Trashed Document', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + deletedAt: '2024-01-02T00:00:00.000Z', + }; + + (useAuth as jest.Mock).mockReturnValue({ + isAuthenticated: true, + accessToken: 'token-restore-test', + }); + + getCloudDocumentSpy.mockResolvedValue({ ydoc, meta: trashedMeta }); + getMyAccessSpy.mockResolvedValue({ + documentId: 'external-id', + allowed: true, + accessLevel: 'OWNER', + owner: true, + }); + + const store = createTestStore(); + function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + } + + const { result } = renderHook(() => useDocument('external-id'), { wrapper: Wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isReadOnly).toBe(true); + expect(result.current.meta?.deletedAt).toBe('2024-01-02T00:00:00.000Z'); + + const restoredMeta = { + ...trashedMeta, + deletedAt: undefined, + }; + loadDocumentSpy.mockResolvedValue({ ydoc, meta: restoredMeta }); + + await act(async () => { + window.dispatchEvent(new CustomEvent('local-documents-changed')); + }); + + await waitFor(() => { + expect(result.current.isReadOnly).toBe(false); + }); + + expect(result.current.meta?.deletedAt).toBeUndefined(); + expect(result.current.accessLevel).toBe('OWNER'); + expect(store.getState().document.meta?.deletedAt).toBeUndefined(); + }); }); From d4ff62e9ef5b43c3d38d8af2cf5c55e74368c549 Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Sun, 31 May 2026 11:23:44 +0530 Subject: [PATCH 3/5] editor: Add trash banner and restrict editing for deleted documents. Now, render a notice banner in the DocToolbar top-right when the document is trashed, offering a "Restore" link to undo deletion. In the Editor, wire up the restore action from the hook, make the document read-only when trashed, and clean up access level checks for guest views. --- web/components/DocToolbar.tsx | 40 +++++++++++++++++++++++++++++++++-- web/components/Editor.tsx | 22 ++++++++++++++++++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/web/components/DocToolbar.tsx b/web/components/DocToolbar.tsx index f144f9c..de86627 100644 --- a/web/components/DocToolbar.tsx +++ b/web/components/DocToolbar.tsx @@ -51,6 +51,10 @@ interface DocToolbarProps { showGuestNotice?: boolean; /** Callback for the guest notice auth CTA */ onGuestNoticeCtaClick?: () => void; + /** Whether to show a trash notice in the top toolbar */ + showTrashNotice?: boolean; + /** Callback to restore the document from trash */ + onRestore?: () => void; } export function DocToolbar({ @@ -65,6 +69,8 @@ export function DocToolbar({ onCommentsToggle, showGuestNotice = false, onGuestNoticeCtaClick, + showTrashNotice = false, + onRestore, }: DocToolbarProps) { const [isShareOpen, setIsShareOpen] = useState(false); const [showOfflineTooltip, setShowOfflineTooltip] = useState(false); @@ -152,6 +158,36 @@ export function DocToolbar({ {/* ── Top-right toolbar ── */}
+ {showTrashNotice && onRestore && ( +
+ This document is in the trash. + + it to make edits. +
+ )} + {shouldShowGuestNotice && (
>({}); - const isGuestSharedView = !isAuthenticated && isSharedDocument && accessLevel === 'VIEW'; + const isGuestSharedView = !isAuthenticated && accessLevel === 'VIEW'; const isOffline = !isOnline; const { pendingEdits } = useYjsPersistence( documentId, @@ -167,6 +168,23 @@ export default function Editor() { window.dispatchEvent(new CustomEvent('open-auth-modal')); }, []); + const [isRestoring, setIsRestoring] = useState(false); + + const handleRestore = useCallback(async () => { + if (isRestoring) { + return; + } + setIsRestoring(true); + try { + await restore(); + } catch (error) { + console.error('Failed to restore document:', error); + alert('Failed to restore document. Please try again.'); + } finally { + setIsRestoring(false); + } + }, [restore, isRestoring]); + // Look at the comment in useOfflineDocumentSelect file to know why we need this workaround. useOfflineDocumentSelect(setOfflineSelectedDocumentId); @@ -276,6 +294,8 @@ export default function Editor() { pendingEdits={pendingEdits} showGuestNotice={isGuestSharedView} onGuestNoticeCtaClick={openAuthModal} + showTrashNotice={!!meta?.deletedAt} + onRestore={handleRestore} showCommentsButton={showCommentsButton} isCommentsSidebarOpen={isCommentsSidebarOpen} openCommentsCount={activeCommentStats.open} From c0bed6dd031c7a184ecb0d08a9b1fc3047452a3d Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Mon, 1 Jun 2026 17:05:12 +0530 Subject: [PATCH 4/5] sidebar: Implement adjustable width and inline trash panel actions. Previously, the sidebar had a fixed width of 256px that could not be customized. Document action flows in the trash panel required opening the standard document actions dropdown, and various items had a larger border radius (rounded-lg). Now, implement click-and-drag resizing of the sidebar between 256px and 480px, storing the user's preference in localStorage. Apply visual design polish to use rounded-sm instead of rounded-lg for sidebar buttons and popup menu items. --- web/components/PopupMenuItem.tsx | 2 +- web/components/Sidebar.tsx | 419 ++++++++++++++++++------------- 2 files changed, 242 insertions(+), 179 deletions(-) diff --git a/web/components/PopupMenuItem.tsx b/web/components/PopupMenuItem.tsx index 24454f1..9fc1d9b 100644 --- a/web/components/PopupMenuItem.tsx +++ b/web/components/PopupMenuItem.tsx @@ -10,7 +10,7 @@ type PopupMenuItemProps = { }; const baseClassName = - 'w-full rounded-lg px-3 py-1.5 text-left text-[13px] transition-colors cursor-pointer flex items-center gap-2'; + 'w-full rounded-sm px-2 py-1.5 text-left text-[13px] transition-colors cursor-pointer flex items-center gap-2'; const iconSlotClassName = 'inline-flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center'; diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx index f65ea93..205a26a 100644 --- a/web/components/Sidebar.tsx +++ b/web/components/Sidebar.tsx @@ -185,7 +185,7 @@ function SidebarDocumentSection({ {[1, 2, 3].map((i) => (
))}
@@ -207,7 +207,7 @@ function SidebarDocumentSection({ > @@ -909,7 +959,7 @@ function Sidebar({ onOpenAuth }: { onOpenAuth: () => void }) {
+

+ {isTrashPanel ? 'Trash' : isSharedPanel ? 'Shared' : 'Private'} +

+
+ +
+ + setSearchQuery(event.target.value)} + placeholder={isTrashPanel ? 'Search trash' : 'Search documents'} + className="w-full rounded-sm border border-sidebar-border bg-sidebar-accent/50 pl-9 pr-9 py-2 text-[13px] text-sidebar-foreground outline-none ring-0 focus:border-sidebar-ring focus:bg-sidebar" + /> + {searchQuery && ( -

- {isTrashPanel ? 'Trash' : isSharedPanel ? 'Shared' : 'Private'} -

-
- -
- - setSearchQuery(event.target.value)} - placeholder={isTrashPanel ? 'Search trash' : 'Search documents'} - className="w-full rounded-lg border border-sidebar-border bg-sidebar-accent/50 pl-9 pr-9 py-2 text-[13px] text-sidebar-foreground outline-none ring-0 focus:border-sidebar-ring focus:bg-sidebar" - /> - {searchQuery && ( - - )} -
+ )}
+
-
- {panelIsLoadingInitial ? ( - - ) : filteredDocuments.length === 0 ? ( -
-

- {searchQuery - ? 'No documents match your search.' - : isTrashPanel - ? 'No documents in trash.' - : isSharedPanel - ? 'No shared documents yet.' - : 'No documents yet.'} -

-
- ) : ( -
    - {filteredDocuments.map((doc) => { - const isActive = doc.id === activeDocId; - return ( -
  • - {isTrashPanel ? ( -
    -
    - - - {doc.meta.title || 'Untitled'} - -
    - - {isAuthenticated && accessToken && ( -
    - - -
    - )} -
    - ) : ( - <> - + + {isAuthenticated && accessToken && ( +
    - - - {doc.meta.title || 'Untitled'} - - - - {isAuthenticated && accessToken && ( - - )} - - )} -
  • - ); - })} - - {panelIsLoadingMore && ( -
  • - + + +
+ )} + + ) : ( + <> + + + {isAuthenticated && accessToken && ( + + )} + + )} - )} + ); + })} - {panelHasMore &&
  • } - - )} - - - + {panelIsLoadingMore && ( +
  • + +
  • + )} + + {panelHasMore &&
  • } + + )} + + )} void }) { void handleConfirmPermanentDelete(); }} /> + + {!isSidebarCollapsed && ( +
    + )} ); } From a58d7826a69bd364d0e6efd298cdb0cc5e176f6c Mon Sep 17 00:00:00 2001 From: santhoshh-kumar Date: Wed, 3 Jun 2026 14:30:57 +0530 Subject: [PATCH 5/5] ui: Polish toolbar icon sizes, padding, and button weight. Previously, the Comments icon and Globe icon had non-standard scaling and stroke weights compared to other toolbar icons. The SharePanel button text used a semibold font weight, and some buttons had slightly inconsistent padding. Now, update the Comments icon size to 18px with 1.75 strokeWidth, and the Globe icon size to 16px. Polish padding for buttons in DocToolbar to align icons and text neatly, and set the SharePanel save button font weight to medium -- eventhough the "Done" button has a semibold font weight, this makes it look similar to the "Done" button. --- web/components/SharePanel.tsx | 2 +- web/icons/Comments.tsx | 2 +- web/icons/Globe.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/components/SharePanel.tsx b/web/components/SharePanel.tsx index 1e31f2a..9cde033 100644 --- a/web/components/SharePanel.tsx +++ b/web/components/SharePanel.tsx @@ -546,7 +546,7 @@ export function SharePanel({ documentId, isOpen, onClose, anchorRef }: SharePane className=" flex-shrink-0 rounded-full bg-[#d7897f] hover:bg-[#C97B71] focus-visible:ring-2 focus-visible:ring-[#C06D5B]/50 focus:bg-[#F2BEB6] - px-5 py-2 text-black/85 font-semibold tracking-wide + px-5 py-2 text-black/85 font-medium tracking-wide text-[13px] active:bg-[#B86D63] active:scale-95 transition-all cursor-pointer " diff --git a/web/icons/Comments.tsx b/web/icons/Comments.tsx index 9264a22..a004165 100644 --- a/web/icons/Comments.tsx +++ b/web/icons/Comments.tsx @@ -1,6 +1,6 @@ import { IconBase, type IconProps } from './IconBase'; -export const Comments = ({ className, size = 20, strokeWidth = 1.5 }: IconProps) => ( +export const Comments = ({ className, size = 18, strokeWidth = 1.75 }: IconProps) => ( diff --git a/web/icons/Globe.tsx b/web/icons/Globe.tsx index 11ddf18..dc2f81b 100644 --- a/web/icons/Globe.tsx +++ b/web/icons/Globe.tsx @@ -1,6 +1,6 @@ import { IconBase, type IconProps } from './IconBase'; -export const Globe = ({ className, size = 15, strokeWidth = 1.75 }: IconProps) => ( +export const Globe = ({ className, size = 16, strokeWidth = 1.75 }: IconProps) => (