diff --git a/api/src/main/java/com/nextdocs/api/document/service/DocumentService.java b/api/src/main/java/com/nextdocs/api/document/service/DocumentService.java index 1ca3a4d..0dfe484 100644 --- a/api/src/main/java/com/nextdocs/api/document/service/DocumentService.java +++ b/api/src/main/java/com/nextdocs/api/document/service/DocumentService.java @@ -118,9 +118,22 @@ public Page list(UUID userId, Pageable pageable, boolean trash public DocumentResponse get(UUID userId, UUID documentId, boolean includeTrashed) { Document document; if (includeTrashed) { - document = documentRepository - .findByIdAndUser_Id(documentId, userId) - .orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + document = documentRepository.findById(documentId).orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND)); + + if (document.getDeletedAt() != null) { + // Document is in trash - only the owner can access it + if (!document.getUser().getId().equals(userId)) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + } else { + // Active document - check if the user is the owner or has valid collaborator/public access + if (!document.getUser().getId().equals(userId)) { + DocumentAccessLevel effectiveAccess = resolveEffectiveNonOwnerAccess(userId, document); + if (effectiveAccess == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + } + } } else { document = findAccessibleActiveDocument(userId, documentId, false); } diff --git a/api/src/test/java/com/nextdocs/api/document/service/DocumentServiceTest.java b/api/src/test/java/com/nextdocs/api/document/service/DocumentServiceTest.java index 4f2d9bc..dacc901 100644 --- a/api/src/test/java/com/nextdocs/api/document/service/DocumentServiceTest.java +++ b/api/src/test/java/com/nextdocs/api/document/service/DocumentServiceTest.java @@ -231,6 +231,54 @@ void update_prefersCollaboratorReadOnlyOverGeneralEditAccess() { verify(documentRepository, never()).save(any(Document.class)); } + @Test + void get_allowsCollaboratorAccessWhenIncludeTrashedIsTrue() { + UUID requesterId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document document = createSharedDocument(documentId, DocumentAccessLevel.VIEW); + + when(documentRepository.findById(documentId)).thenReturn(Optional.of(document)); + when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) + .thenReturn(Optional.empty()); // link access allows VIEW + + var response = documentService.get(requesterId, documentId, true); + + assertEquals(documentId, response.id()); + assertEquals("Shared doc", response.title()); + } + + @Test + void get_rejectsCollaboratorAccessWhenDocumentIsTrashedAndIncludeTrashedIsTrue() { + UUID requesterId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document document = createSharedDocument(documentId, DocumentAccessLevel.VIEW); + document.setDeletedAt(OffsetDateTime.now(ZoneOffset.UTC)); + + when(documentRepository.findById(documentId)).thenReturn(Optional.of(document)); + + ApiException exception = + assertThrows(ApiException.class, () -> documentService.get(requesterId, documentId, true)); + + assertEquals(ErrorCode.NOT_FOUND, exception.getErrorCode()); + } + + @Test + void get_rejectsAccessWhenCollaboratorHasNoAccessAndIncludeTrashedIsTrue() { + UUID requesterId = UUID.randomUUID(); + UUID documentId = UUID.randomUUID(); + Document document = createSharedDocument(documentId, DocumentAccessLevel.VIEW); + document.setGeneralAccessMode(DocumentGeneralAccessMode.RESTRICTED); + + when(documentRepository.findById(documentId)).thenReturn(Optional.of(document)); + when(collaboratorRepository.findByDocument_IdAndUser_Id(documentId, requesterId)) + .thenReturn(Optional.empty()); + + ApiException exception = + assertThrows(ApiException.class, () -> documentService.get(requesterId, documentId, true)); + + assertEquals(ErrorCode.NOT_FOUND, exception.getErrorCode()); + } + private static Document createSharedDocument(UUID documentId, DocumentAccessLevel linkAccessLevel) { User owner = User.builder() .id(UUID.randomUUID()) diff --git a/realtime/tests/unit/server.test.ts b/realtime/tests/unit/server.test.ts index 3ab2b0c..bcc0db6 100644 --- a/realtime/tests/unit/server.test.ts +++ b/realtime/tests/unit/server.test.ts @@ -30,6 +30,7 @@ jest.mock('../../src/logger', () => ({ jest.mock('../../src/yjs-utils', () => ({ __esModule: true, setupWSConnection: jest.fn(), + updateConnectionAccessLevel: jest.fn(), })); jest.mock('../../src/config', () => ({ diff --git a/realtime/tests/unit/yjs-utils.test.ts b/realtime/tests/unit/yjs-utils.test.ts index 166c427..c3fc609 100644 --- a/realtime/tests/unit/yjs-utils.test.ts +++ b/realtime/tests/unit/yjs-utils.test.ts @@ -40,6 +40,8 @@ describe('Yjs Utils', () => { }); afterEach(() => { + docs.forEach((doc) => doc.destroy()); + docs.clear(); jest.clearAllMocks(); }); diff --git a/web/app/doc/[id]/page.tsx b/web/app/doc/[id]/page.tsx index 11b6abb..52287b8 100644 --- a/web/app/doc/[id]/page.tsx +++ b/web/app/doc/[id]/page.tsx @@ -2,7 +2,7 @@ import dynamic from 'next/dynamic'; -const Editor = dynamic(() => import('@/components/Editor'), { ssr: false }); +const Editor = dynamic(() => import('@/components/editor'), { ssr: false }); export default function DocPage() { return ; diff --git a/web/components/AppShell.tsx b/web/components/AppShell.tsx index 5ac859c..42dc103 100644 --- a/web/components/AppShell.tsx +++ b/web/components/AppShell.tsx @@ -1,13 +1,23 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { Suspense } from 'react'; -import Sidebar from '@/components/Sidebar'; +import Sidebar from '@/components/sidebar'; import { AuthModal } from '@/components/AuthModal'; +import { ToastContainer } from '@/components/ToastContainer'; import { LocalDocsPromotionModal } from '@/components/LocalDocsPromotionModal'; import { RegistrationSyncOverlay } from '@/components/RegistrationSyncOverlay'; -import { useAppDispatch } from '@/stores/hooks'; +import { useAppDispatch, useAppSelector } from '@/stores/hooks'; import { refreshSessionThunk } from '@/stores/auth/auth.slice'; +import { + setAuthModalOpen, + setLocalDocsModalOpen, + setLocalDocsToPromote, + setImportingLocalDocs, + setLocalDocsError, + setRegistrationSyncOverlayOpen, + resetPromotionFlow, +} from '@/stores/ui/ui.slice'; import { useAuth } from '@/hooks/useAuth.hook'; import { documentService } from '@/services/document.service'; import { isUntitledTitle, isEmptyLocalDocument } from '@/lib/document-content.util'; @@ -57,15 +67,15 @@ export function getDocsEligibleForAccountMove(docs: StoredDocument[]): StoredDoc export function AppShell({ children }: { children: React.ReactNode }) { const dispatch = useAppDispatch(); - const [isAuthOpen, setIsAuthOpen] = useState(false); - const [isLocalDocsModalOpen, setIsLocalDocsModalOpen] = useState(false); - const [localDocsToPromote, setLocalDocsToPromote] = useState([]); - const [isImportingLocalDocs, setIsImportingLocalDocs] = useState(false); - const [localDocsError, setLocalDocsError] = useState(null); - const [isRegistrationSyncOverlayOpen, setIsRegistrationSyncOverlayOpen] = useState(false); - const openAuthModal = useCallback(() => { - setIsAuthOpen(true); - }, []); + const isAuthOpen = useAppSelector((state) => state.ui.isAuthModalOpen); + const isLocalDocsModalOpen = useAppSelector((state) => state.ui.isLocalDocsModalOpen); + const localDocsToPromote = useAppSelector((state) => state.ui.localDocsToPromote); + const isImportingLocalDocs = useAppSelector((state) => state.ui.isImportingLocalDocs); + const localDocsError = useAppSelector((state) => state.ui.localDocsError); + const isRegistrationSyncOverlayOpen = useAppSelector( + (state) => state.ui.isRegistrationSyncOverlayOpen + ); + const { user, isTokenExpiringSoon, isAuthenticated, accessToken, lastAuthAction } = useAuth(); const didPromptImportRef = useRef(false); const ownsLocalPromotionLockRef = useRef(false); @@ -145,22 +155,18 @@ export function AppShell({ children }: { children: React.ReactNode }) { const closePromotionFlow = useCallback(() => { didPromptImportRef.current = true; - setIsLocalDocsModalOpen(false); - setLocalDocsToPromote([]); - setIsImportingLocalDocs(false); - setLocalDocsError(null); - setIsRegistrationSyncOverlayOpen(false); + dispatch(resetPromotionFlow()); releasePromotionLockIfOwned(); - }, [releasePromotionLockIfOwned]); + }, [dispatch, releasePromotionLockIfOwned]); // Run exactly once on mount to restore session from the refresh-token cookie useEffect(() => { dispatch(refreshSessionThunk()); - const handleOpenAuth = () => setIsAuthOpen(true); + const handleOpenAuth = () => dispatch(setAuthModalOpen(true)); window.addEventListener('open-auth-modal', handleOpenAuth); return () => window.removeEventListener('open-auth-modal', handleOpenAuth); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, [dispatch]); // Auto-refresh token just before it expires to prevent UX drops useEffect(() => { @@ -241,16 +247,16 @@ export function AppShell({ children }: { children: React.ReactNode }) { if (cancelled || promotableLocalDocs.length === 0) { didPromptImportRef.current = true; - setIsRegistrationSyncOverlayOpen(false); + dispatch(setRegistrationSyncOverlayOpen(false)); releasePromotionLockIfOwned(); return; } if (isRegistrationFlow) { - setLocalDocsToPromote(promotableLocalDocs); - setIsRegistrationSyncOverlayOpen(true); - setIsImportingLocalDocs(true); - setLocalDocsError(null); + dispatch(setLocalDocsToPromote(promotableLocalDocs)); + dispatch(setRegistrationSyncOverlayOpen(true)); + dispatch(setImportingLocalDocs(true)); + dispatch(setLocalDocsError(null)); await waitForNextPaint(); const syncStartedAt = Date.now(); @@ -266,14 +272,16 @@ export function AppShell({ children }: { children: React.ReactNode }) { return; } - setLocalDocsToPromote(promotableLocalDocs); - setIsLocalDocsModalOpen(true); + dispatch(setLocalDocsToPromote(promotableLocalDocs)); + dispatch(setLocalDocsModalOpen(true)); } catch (error) { console.error('Failed to promote local documents:', error); - setIsImportingLocalDocs(false); - setIsRegistrationSyncOverlayOpen(lastAuthAction === 'register'); - setLocalDocsError( - error instanceof Error ? error.message : 'Failed to promote local documents.' + dispatch(setImportingLocalDocs(false)); + dispatch(setRegistrationSyncOverlayOpen(lastAuthAction === 'register')); + dispatch( + setLocalDocsError( + error instanceof Error ? error.message : 'Failed to promote local documents.' + ) ); if (!isRegistrationFlow) { @@ -303,6 +311,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { moveLocalDocsToAccount, releasePromotionLockIfOwned, waitForPromotionInFlight, + dispatch, ]); const runMoveToAccount = useCallback(async () => { @@ -311,10 +320,10 @@ export function AppShell({ children }: { children: React.ReactNode }) { } try { - setIsLocalDocsModalOpen(false); - setIsRegistrationSyncOverlayOpen(true); - setIsImportingLocalDocs(true); - setLocalDocsError(null); + dispatch(setLocalDocsModalOpen(false)); + dispatch(setRegistrationSyncOverlayOpen(true)); + dispatch(setImportingLocalDocs(true)); + dispatch(setLocalDocsError(null)); await waitForNextPaint(); const syncStartedAt = Date.now(); @@ -328,12 +337,21 @@ export function AppShell({ children }: { children: React.ReactNode }) { closePromotionFlow(); } catch (error) { - setIsImportingLocalDocs(false); - setLocalDocsError( - error instanceof Error ? error.message : 'Failed to promote local documents.' + dispatch(setImportingLocalDocs(false)); + dispatch( + setLocalDocsError( + error instanceof Error ? error.message : 'Failed to promote local documents.' + ) ); } - }, [accessToken, user?.id, localDocsToPromote, moveLocalDocsToAccount, closePromotionFlow]); + }, [ + accessToken, + user?.id, + localDocsToPromote, + moveLocalDocsToAccount, + closePromotionFlow, + dispatch, + ]); const handleDiscardLocalData = async () => { if (localDocsToPromote.length === 0) { @@ -342,22 +360,22 @@ export function AppShell({ children }: { children: React.ReactNode }) { } try { - setIsImportingLocalDocs(true); - setLocalDocsError(null); + dispatch(setImportingLocalDocs(true)); + dispatch(setLocalDocsError(null)); await documentService.deleteGuestDocumentsByIds(localDocsToPromote.map((doc) => doc.id)); closePromotionFlow(); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to discard local documents.'; - setLocalDocsError(message); - setIsImportingLocalDocs(false); + dispatch(setLocalDocsError(message)); + dispatch(setImportingLocalDocs(false)); } }; return (
- +
{/* By using a nested flex-1 overflow-y-auto child, we are effectively telling the browser that the scrollable region starts below the toolbar's height.*/} @@ -368,7 +386,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
- {isAuthOpen && setIsAuthOpen(false)} />} + {isAuthOpen && dispatch(setAuthModalOpen(false))} />} {isLocalDocsModalOpen && ( )} + ); } diff --git a/web/components/AuthModal.tsx b/web/components/AuthModal.tsx index 7bc64dc..82d355b 100644 --- a/web/components/AuthModal.tsx +++ b/web/components/AuthModal.tsx @@ -4,6 +4,7 @@ import { useEffect, useId, useRef, useState, type FormEvent } from 'react'; import { useAppDispatch } from '@/stores/hooks'; import { clearError } from '@/stores/auth/auth.slice'; import { useAuth } from '@/hooks/useAuth.hook'; +import { GitHub, Google } from '@/icons'; type Mode = 'login' | 'signup'; @@ -276,47 +277,9 @@ function OAuthButton({ provider }: { provider: 'google' | 'github' }) { className="w-full flex items-center justify-center gap-2 rounded-md border border-border bg-background px-4 py-2 text-sm text-foreground/50 cursor-not-allowed opacity-60" > - {provider === 'google' ? : } + {provider === 'google' ? : } Continue with {provider === 'google' ? 'Google' : 'GitHub'} ); } - -function GoogleIcon() { - return ( - - ); -} - -function GitHubIcon() { - return ( - - ); -} diff --git a/web/components/Editor.tsx b/web/components/Editor.tsx deleted file mode 100644 index e41ba67..0000000 --- a/web/components/Editor.tsx +++ /dev/null @@ -1,889 +0,0 @@ -'use client'; - -import '@blocknote/core/fonts/inter.css'; -import { - CommentsExtension, - DefaultThreadStoreAuth, - ThreadStoreAuth, - YjsThreadStore, - type User as CommentUser, -} from '@blocknote/core/comments'; -import { en } from '@blocknote/core/locales'; -import { - FloatingComposerController, - FloatingThreadController, - useCreateBlockNote, -} from '@blocknote/react'; -import { BlockNoteView } from '@blocknote/shadcn'; -import '@blocknote/shadcn/style.css'; -import { useParams, useRouter, useSearchParams } from 'next/navigation'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { DocToolbar } from '@/components/DocToolbar'; -import { DocumentErrorPanel } from '@/components/DocumentErrorPanel'; -import { CommentsSidebar, type CommentThreadStats } from '@/components/comments/CommentsSidebar'; -import { useAuth } from '@/hooks/useAuth.hook'; -import { useDocument } from '@/hooks/useDocument.hook'; -import { useNetworkStatus } from '@/hooks/useNetworkStatus.hook'; -import { useOfflineDocumentSelect } from '@/hooks/useOfflineDocumentSelect.hook'; -import { useTheme } from '@/hooks/useTheme.hook'; -import { useYjsPersistence } from '@/hooks/useYjsPersistence.hook'; -import { Send } from '@/icons/Send'; -import { getPresenceColor } from '@/lib/realtime.util'; -import { documentService, type DocumentAccessLevel } from '@/services/document.service'; -import type { AuthUser } from '@/stores/auth/auth.types'; -import type { CommentsFilter, CommentsSort } from '@/components/comments/CommentProvider'; -import type { DocumentMeta } from '@/types/document.types'; -import type * as Y from 'yjs'; -import type { WebsocketProvider } from 'y-websocket'; - -const EMPTY_COMMENT_STATS: CommentThreadStats = { open: 0, resolved: 0, all: 0 }; -const COMMENT_USER_CACHE_TTL_MS = 20_000; -const COMMENT_USERS_MAP_KEY = 'comment-users'; - -function mapAccessLevelToCommentRole( - accessLevel: DocumentAccessLevel | null -): 'comment' | 'editor' { - return accessLevel === 'COMMENT' ? 'comment' : 'editor'; -} - -class ReadOnlyThreadStoreAuth extends ThreadStoreAuth { - canCreateThread(): boolean { - return false; - } - - canAddComment(): boolean { - return false; - } - - canUpdateComment(): boolean { - return false; - } - - canDeleteComment(): boolean { - return false; - } - - canDeleteThread(): boolean { - return false; - } - - canResolveThread(): boolean { - return false; - } - - canUnresolveThread(): boolean { - return false; - } - - canAddReaction(): boolean { - return false; - } - - canDeleteReaction(): boolean { - return false; - } -} - -interface SharedCommentUserProfile { - username: string; - avatarUrl: string | null; -} - -function parseSharedCommentUserProfile(raw: unknown): SharedCommentUserProfile | null { - if (typeof raw !== 'string') { - return null; - } - - try { - const parsed = JSON.parse(raw) as Partial; - if (!parsed || typeof parsed.username !== 'string' || parsed.username.trim().length === 0) { - return null; - } - - return { - username: parsed.username, - avatarUrl: - typeof parsed.avatarUrl === 'string' && parsed.avatarUrl.trim().length > 0 - ? parsed.avatarUrl - : null, - }; - } catch { - return null; - } -} - -function buildFallbackAvatar(seed: string, username: string): string { - const initial = (username.trim()[0] ?? 'U').toUpperCase(); - const fill = getPresenceColor(seed || initial); - const svg = `${initial}`; - return `data:image/svg+xml,${encodeURIComponent(svg)}`; -} - -export default function Editor() { - const params = useParams(); - const router = useRouter(); - const searchParams = useSearchParams(); - const idParam = params?.id; - const routeDocumentId = Array.isArray(idParam) ? idParam[0] : idParam; - - const [offlineSelectedDocumentId, setOfflineSelectedDocumentId] = useState(null); - const effectiveOfflineSelectedDocumentId = - offlineSelectedDocumentId === routeDocumentId ? null : offlineSelectedDocumentId; - const effectiveDocumentId = effectiveOfflineSelectedDocumentId ?? routeDocumentId ?? ''; - const searchParamsString = searchParams.toString(); - const isSharedDocument = searchParams.get('share') === '1'; - const { isAuthenticated, accessToken, user } = useAuth(); - const { isOnline } = useNetworkStatus(); - const { - documentId, - ydoc, - meta, - accessLevel, - isReadOnly, - realtimeProvider, - errorState, - isLoading, - error, - updateMeta, - restore, - } = useDocument(effectiveDocumentId, { isSharedDocument }); - const [showLoading, setShowLoading] = useState(false); - const [showCommentsSidebar, setShowCommentsSidebar] = useState(false); - const [commentsFilter, setCommentsFilter] = useState('open'); - const [commentsSort, setCommentsSort] = useState('position'); - const [commentStatsByDocument, setCommentStatsByDocument] = useState< - Record - >({}); - const isGuestSharedView = !isAuthenticated && accessLevel === 'VIEW'; - const isOffline = !isOnline; - const { pendingEdits } = useYjsPersistence( - documentId, - ydoc, - meta, - isReadOnly || isGuestSharedView, - !(isReadOnly || isGuestSharedView) - ); - - const openAuthModal = useCallback(() => { - 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); - - useEffect(() => { - if (!isOnline || !routeDocumentId) { - return; - } - - if (!isLoading && documentId && routeDocumentId !== documentId) { - const preservedQuery = isSharedDocument && searchParamsString ? `?${searchParamsString}` : ''; - router.replace(`/doc/${documentId}${preservedQuery}`); - } - }, [ - isLoading, - routeDocumentId, - documentId, - router, - isOnline, - isSharedDocument, - searchParamsString, - ]); - - useEffect(() => { - let timer: NodeJS.Timeout; - if (isLoading) { - timer = setTimeout(() => setShowLoading(true), 300); - } else { - timer = setTimeout(() => setShowLoading(false), 0); - } - return () => clearTimeout(timer); - }, [effectiveDocumentId, isLoading]); - - const commentsFeatureEnabled = accessLevel !== null; - const showCommentsButton = !!user?.id && accessLevel !== 'VIEW'; - const isCommentsSidebarOpen = showCommentsButton ? showCommentsSidebar : false; - const activeCommentStats = commentStatsByDocument[documentId] ?? EMPTY_COMMENT_STATS; - - useEffect(() => { - if (!showCommentsButton) { - return; - } - - const onKeyDown = (event: KeyboardEvent) => { - const shouldToggle = - (event.metaKey || event.ctrlKey) && - event.altKey && - event.shiftKey && - event.key.toLowerCase() === 'a'; - - if (!shouldToggle) { - return; - } - - event.preventDefault(); - setShowCommentsSidebar((prev) => !prev); - }; - - window.addEventListener('keydown', onKeyDown); - return () => { - window.removeEventListener('keydown', onKeyDown); - }; - }, [showCommentsButton]); - - if (errorState) { - return ( - - ); - } - - if (error) { - return ( - - ); - } - - if (isLoading || !ydoc || !meta) { - return ( -
- {showLoading && ( - // TODO: Add a spinner/loading animation here instead of just text. - // Maybe we can also change the placement of the loading indicator. -
- Loading document... -
- )} -
- ); - } - - return ( - <> - setShowCommentsSidebar((prev) => !prev)} - /> - setShowCommentsSidebar(false)} - onCommentsThreadStatsChange={(stats) => { - setCommentStatsByDocument((prev) => { - const current = prev[documentId]; - if ( - current && - current.open === stats.open && - current.resolved === stats.resolved && - current.all === stats.all - ) { - return prev; - } - - return { - ...prev, - [documentId]: stats, - }; - }); - }} - /> - - ); -} - -// We separate this component to ensure BlockNote editor is only created -// after the Yjs document is fully loaded from IndexedDB -function EditorContent({ - documentId, - ydoc, - meta, - updateMeta, - isReadOnly, - accessLevel, - realtimeProvider, - user, - isAuthenticated, - accessToken, - commentsFeatureEnabled, - commentsUiEnabled, - commentsSidebarOpen, - commentsFilter, - commentsSort, - onCommentsFilterChange, - onCommentsSortChange, - onCommentsClose, - onCommentsThreadStatsChange, -}: { - documentId: string; - ydoc: Y.Doc; - meta: DocumentMeta; - updateMeta: (updates: Partial) => void; - isReadOnly: boolean; - accessLevel: DocumentAccessLevel | null; - realtimeProvider: WebsocketProvider | null; - user: AuthUser | null; - isAuthenticated: boolean; - accessToken: string | null; - commentsFeatureEnabled: boolean; - commentsUiEnabled: boolean; - commentsSidebarOpen: boolean; - commentsFilter: CommentsFilter; - commentsSort: CommentsSort; - onCommentsFilterChange: (filter: CommentsFilter) => void; - onCommentsSortChange: (sort: CommentsSort) => void; - onCommentsClose: () => void; - onCommentsThreadStatsChange: (stats: CommentThreadStats) => void; -}) { - const { resolvedTheme } = useTheme(); - const sendIconTemplateRef = useRef(null); - - const collaboratorCache = useRef>(new Map()); - const collaboratorCacheUpdatedAt = useRef(0); - - const commentsDictionary = useMemo( - () => ({ - ...en, - placeholders: { - ...en.placeholders, - new_comment: 'Add comment...', - comment_reply: 'Add comment...', - }, - comments: { - ...en.comments, - save_button_text: 'Send', - }, - }), - [] - ); - - const activeCommentUser = useMemo(() => { - const id = user?.id || 'anonymous'; - const username = user?.displayName || user?.email || 'Anonymous'; - return { - id, - username, - avatarUrl: user?.avatarUrl || buildFallbackAvatar(id, username), - }; - }, [user?.id, user?.displayName, user?.email, user?.avatarUrl]); - - const commentRole = useMemo(() => mapAccessLevelToCommentRole(accessLevel), [accessLevel]); - const canComment = accessLevel === 'COMMENT' || accessLevel === 'EDIT' || accessLevel === 'OWNER'; - const isViewer = accessLevel === 'VIEW'; - const sharedCommentUsers = useMemo(() => ydoc.getMap(COMMENT_USERS_MAP_KEY), [ydoc]); - - useEffect(() => { - if (!isAuthenticated || !activeCommentUser.id || activeCommentUser.id === 'anonymous') { - return; - } - - const serializedProfile = JSON.stringify({ - username: activeCommentUser.username, - avatarUrl: activeCommentUser.avatarUrl ?? null, - } satisfies SharedCommentUserProfile); - - if (sharedCommentUsers.get(activeCommentUser.id) !== serializedProfile) { - sharedCommentUsers.set(activeCommentUser.id, serializedProfile); - } - }, [ - activeCommentUser.id, - activeCommentUser.username, - activeCommentUser.avatarUrl, - isAuthenticated, - sharedCommentUsers, - ]); - - const resolveUsers = useCallback( - async (userIds: string[]): Promise => { - if (userIds.length === 0) { - return []; - } - - const now = Date.now(); - const shouldRefreshCollaborators = - isAuthenticated && - !!accessToken && - now - collaboratorCacheUpdatedAt.current > COMMENT_USER_CACHE_TTL_MS; - - if (shouldRefreshCollaborators) { - try { - const collaborators = await documentService.listCollaborators(documentId, accessToken); - const nextCollaborators = new Map(); - - for (const collaborator of collaborators) { - const username = collaborator.displayName || collaborator.email; - nextCollaborators.set(collaborator.userId, { - id: collaborator.userId, - username, - avatarUrl: buildFallbackAvatar(collaborator.userId, username), - }); - } - - collaboratorCache.current = nextCollaborators; - } catch (error) { - console.warn('Failed to resolve collaborators for comment users:', error); - } finally { - collaboratorCacheUpdatedAt.current = Date.now(); - } - } - - const usersById = new Map(collaboratorCache.current); - usersById.set(activeCommentUser.id, activeCommentUser); - - return userIds.map((rawId) => { - const id = rawId || 'anonymous'; - const cached = usersById.get(id); - - if (cached) { - return cached; - } - - const sharedProfile = parseSharedCommentUserProfile(sharedCommentUsers.get(id)); - if (sharedProfile) { - return { - id, - username: sharedProfile.username, - avatarUrl: sharedProfile.avatarUrl || buildFallbackAvatar(id, sharedProfile.username), - }; - } - - const fallbackName = - id === activeCommentUser.id ? activeCommentUser.username : `User ${id.slice(0, 6)}`; - return { - id, - username: fallbackName, - avatarUrl: buildFallbackAvatar(id, fallbackName), - }; - }); - }, - [accessToken, activeCommentUser, documentId, isAuthenticated, sharedCommentUsers] - ); - - const threadStore = useMemo(() => { - if (!commentsFeatureEnabled) { - return undefined; - } - - const auth = canComment - ? new DefaultThreadStoreAuth(activeCommentUser.id, commentRole) - : new ReadOnlyThreadStoreAuth(); - - return new YjsThreadStore(activeCommentUser.id, ydoc.getMap('threads'), auth); - }, [activeCommentUser.id, canComment, commentRole, commentsFeatureEnabled, ydoc]); - - const editor = useCreateBlockNote( - { - collaboration: { - provider: realtimeProvider || undefined, - fragment: ydoc.getXmlFragment('blocknote'), - user: { - name: activeCommentUser.username, - color: getPresenceColor(activeCommentUser.id || documentId), - }, - }, - dictionary: commentsDictionary, - // Keep comments extension enabled in view-only mode so existing commented text stays visible. - extensions: - commentsFeatureEnabled && threadStore - ? [CommentsExtension({ threadStore, resolveUsers })] - : [], - }, - [ - activeCommentUser.id, - activeCommentUser.username, - commentsDictionary, - commentsFeatureEnabled, - documentId, - realtimeProvider, - resolveUsers, - threadStore, - ydoc, - ] - ); - - useEffect(() => { - if (!commentsUiEnabled) { - return; - } - - const selector = - '.nd-floating-composer .bn-comment-actions button, .bn-thread .bn-thread-composer .bn-comment-actions button'; - - const normalizeComposerText = (value: string): string => { - const lines = value - .replace(/\r\n?/g, '\n') - .replace(/\u00A0/g, ' ') - .replace(/[\u200B-\u200D\uFEFF]/g, '') - .split('\n') - .map((line) => line.replace(/[ \t]+$/g, '')); - - while (lines.length > 0 && lines[0].trim().length === 0) { - lines.shift(); - } - while (lines.length > 0 && lines[lines.length - 1].trim().length === 0) { - lines.pop(); - } - - if (lines.length > 0) { - lines[0] = lines[0].replace(/^[ \t]+/g, ''); - } - - return lines.join('\n'); - }; - - const getComposerRawText = (editorSurface: HTMLElement | null): string => { - if (!editorSurface) { - return ''; - } - return (editorSurface.innerText || editorSurface.textContent || '').replace(/\r\n?/g, '\n'); - }; - - const getComposerEditorSurface = (button: HTMLButtonElement): HTMLElement | null => { - const composerRoot = button.closest( - '.bn-thread-composer, .nd-floating-composer .bn-thread' - ); - return composerRoot?.querySelector('.bn-comment-editor .bn-editor') ?? null; - }; - - const hasComposerContent = (button: HTMLButtonElement): boolean => { - const editorSurface = getComposerEditorSurface(button); - const rawText = getComposerRawText(editorSurface); - const normalized = normalizeComposerText(rawText); - return normalized.length > 0; - }; - - const syncComposerSendButtons = () => { - document.querySelectorAll(selector).forEach((button) => { - button.setAttribute('data-nd-send-icon-only', 'true'); - - if (!button.querySelector('.nd-comment-send-icon')) { - const iconTemplate = sendIconTemplateRef.current?.querySelector('svg'); - if (iconTemplate) { - const wrapper = document.createElement('span'); - wrapper.className = 'nd-comment-send-icon'; - wrapper.setAttribute('aria-hidden', 'true'); - wrapper.append(iconTemplate.cloneNode(true)); - button.replaceChildren(wrapper); - } - } - - if (button.dataset.ndNormalizeBound !== 'true') { - button.dataset.ndNormalizeBound = 'true'; - button.addEventListener( - 'click', - (event) => { - if (button.dataset.ndNormalizeBypass === 'true') { - button.dataset.ndNormalizeBypass = 'false'; - return; - } - - const editorSurface = getComposerEditorSurface(button); - if (!editorSurface) { - return; - } - - const rawText = getComposerRawText(editorSurface); - const normalized = normalizeComposerText(rawText); - - if (normalized.length === 0) { - event.preventDefault(); - event.stopPropagation(); - return; - } - - if (normalized !== rawText) { - event.preventDefault(); - event.stopPropagation(); - editorSurface.textContent = normalized; - editorSurface.dispatchEvent(new Event('input', { bubbles: true })); - - // Submit on next microtask so BlockNote can ingest the normalized draft first. - button.dataset.ndNormalizeBypass = 'true'; - queueMicrotask(() => { - button.click(); - }); - } - }, - true - ); - } - - const hasContent = hasComposerContent(button); - const actionsWrapper = button.closest('.bn-comment-actions-wrapper'); - button.hidden = !hasContent; - button.setAttribute('aria-hidden', String(!hasContent)); - if (actionsWrapper) { - actionsWrapper.hidden = !hasContent; - } - }); - }; - - // WORKAROUND: BlockNote's floating composer can still render a hardcoded - // text label. We patch the live button to icon-only until upstream - // exposes a reliable API override. - // - // TODO: Maybe we can create our own implemenentation instead of relying on - // blocknote to overcome these difficulties. - // - // Throttle sync to avoid excessive processing during rapid mutations. - // BlockNote composers are portal-rendered to document.body, so we must - // observe body — but we batch sync calls to reduce overhead. - let syncScheduled = false; - const observer = new MutationObserver(() => { - if (syncScheduled) return; - syncScheduled = true; - requestAnimationFrame(() => { - syncScheduled = false; - syncComposerSendButtons(); - }); - }); - - observer.observe(document.body, { - childList: true, - subtree: true, - characterData: true, - }); - - return () => { - observer.disconnect(); - }; - }, [commentsUiEnabled]); - - const textareaRef = useRef(null); - const focusRequested = useRef(false); - - const [isEditorVisible, setIsEditorVisible] = useState(() => { - const blocks = editor.document; - const hasTitle = meta.title !== 'Untitled'; - const hasContent = - blocks.length > 1 || - (blocks.length === 1 && Array.isArray(blocks[0].content) && blocks[0].content.length > 0); - return hasTitle || hasContent; - }); - - useEffect(() => { - // Check if the document has actual content after Yjs sync - const blocks = editor.document; - const hasContent = - blocks.length > 1 || - (blocks.length === 1 && Array.isArray(blocks[0].content) && blocks[0].content.length > 0); - - // If it has content (e.g. from collab sync), ensure editor is visible - if (hasContent) { - setIsEditorVisible(true); - } - }, [editor.document]); - - useEffect(() => { - if (isEditorVisible && focusRequested.current) { - // Small delay to ensure the DOM is ready and BlockNote is initialized - const timer = setTimeout(() => { - editor.focus(); - focusRequested.current = false; - }, 50); - return () => clearTimeout(timer); - } - }, [isEditorVisible, editor]); - - const adjustTextareaHeight = () => { - const textarea = textareaRef.current; - if (textarea) { - textarea.style.height = 'auto'; - textarea.style.height = `${textarea.scrollHeight}px`; - } - }; - - useEffect(() => { - adjustTextareaHeight(); - }, [meta.title]); - - useEffect(() => { - // Auto-focus the title input when the editor mounts - // only if this is a new document (untitled) - if (!isReadOnly && textareaRef.current && meta.title === 'Untitled') { - textareaRef.current.focus(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Intentionally only run on mount to avoid stealing focus later - - const handleTitleChange = (e: React.ChangeEvent) => { - if (isReadOnly) { - return; - } - - updateMeta({ title: e.target.value }); - adjustTextareaHeight(); - }; - - const handleTitleBlur = () => { - if (isReadOnly) { - return; - } - - // Normalize empty titles to 'Untitled' to maintain consistency - if (!meta.title || meta.title.trim() === '') { - updateMeta({ title: 'Untitled' }); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (isReadOnly) { - return; - } - - if (e.key === 'Enter') { - e.preventDefault(); - if (!isEditorVisible) { - setIsEditorVisible(true); - focusRequested.current = true; - } else { - editor.focus(); - } - } - }; - - const handleEditorPointerDownCapture = useCallback( - (event: React.PointerEvent) => { - if (accessLevel !== 'COMMENT') { - return; - } - - const target = event.target; - if (!(target instanceof HTMLElement)) { - return; - } - - if (!target.closest('.bn-formatting-toolbar')) { - return; - } - - // Keep editor selection stable so Add Comment works on first click. - event.preventDefault(); - editor.focus(); - }, - [accessLevel, editor] - ); - - return ( -
-
-