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

-
- ) : ( -
+ )} + + ) : ( + <> + + + {isAuthenticated && accessToken && ( + + )} + + )} - )} + ); + })} - {panelHasMore &&
  • } - - )} - - - + {panelIsLoadingMore && ( +
  • + +
  • + )} + + {panelHasMore &&
  • } + + )} + + )} void }) { void handleConfirmPermanentDelete(); }} /> + + {!isSidebarCollapsed && ( +
    + )} ); } 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/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) => ( 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(); } 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(); + }); });