diff --git a/src/App.tsx b/src/App.tsx index daa742c9..9e769cfc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,7 @@ import { Excalidraw as ExcalidrawComponent, useHandleLibrary, Sidebar, isElement import '@excalidraw/excalidraw/index.css' import type { LibraryItems } from '@nextcloud/excalidraw/dist/types/excalidraw/types' import { useExcalidrawStore } from './stores/useExcalidrawStore' -import { useWhiteboardConfigStore } from './stores/useWhiteboardConfigStore' +import { selectEffectiveReadOnly, useWhiteboardConfigStore } from './stores/useWhiteboardConfigStore' import { useThemeHandling } from './hooks/useThemeHandling' import { useCollaboration } from './hooks/useCollaboration' import { useSmartPicker } from './hooks/useSmartPicker' @@ -54,6 +54,7 @@ import { VotingSidebar } from './components/VotingSidebar' import { useVoting } from './hooks/useVoting' import { useContextMenuFilter } from './hooks/useContextMenuFilter' import { useDisableExternalLibraries } from './hooks/useDisableExternalLibraries' +import { useLocalSyncLeader } from './hooks/useLocalSyncLeader' const Excalidraw = memo(ExcalidrawComponent) @@ -101,6 +102,7 @@ export default function App({ const { setConfig, + effectiveReadOnly, gridModeEnabled, initialDataPromise, resetInitialDataPromise, @@ -108,6 +110,7 @@ export default function App({ setGridModeEnabled, } = useWhiteboardConfigStore(useShallow(state => ({ setConfig: state.setConfig, + effectiveReadOnly: selectEffectiveReadOnly(state), gridModeEnabled: state.gridModeEnabled, initialDataPromise: state.initialDataPromise, resetInitialDataPromise: state.resetInitialDataPromise, @@ -129,6 +132,7 @@ export default function App({ const { renderTable } = useTableInsertion() const { renderAssistant } = useAssistant() const { renderEmojiPicker } = useEmojiPicker() + const { isPassiveFollower } = useLocalSyncLeader() const { onChange: onChangeSync, onPointerUpdate } = useSync() const { fetchLibraryItems, updateLibraryItems, isLibraryLoaded, setIsLibraryLoaded } = useLibrary() useCollaboration() @@ -346,7 +350,7 @@ export default function App({ const [commentSidebarDocked, setCommentSidebarDocked] = useState(false) const { renderComment, commentThreads, panToThread, deleteThread } = useComment({ activeCommentThreadId, - isReadOnly, + isReadOnly: effectiveReadOnly, onCommentThreadClick: (commentThreadId) => { setActiveCommentThreadId(commentThreadId) if (commentThreadId) { @@ -440,12 +444,12 @@ export default function App({ }, [maxImageSizeBytes, maxImageSizeMb]) const handleOnChange = useCallback(() => { - if (isVersionPreview) { + if (isVersionPreview || effectiveReadOnly) { return } if (!excalidrawAPI || !normalizedFileId || isLoading) return onChangeSync() - }, [excalidrawAPI, normalizedFileId, isLoading, onChangeSync, isVersionPreview]) + }, [effectiveReadOnly, excalidrawAPI, normalizedFileId, isLoading, onChangeSync, isVersionPreview]) const canvasActions = useMemo(() => { if (isVersionPreview) { @@ -511,6 +515,25 @@ export default function App({
{!isVersionPreview && } + {isPassiveFollower && !isVersionPreview && ( +
+ {t('whiteboard', 'Another tab is the active editor for this board.')} +
+ )} {isVersionPreview && ( { activeCommentThreadId === threadId && setActiveCommentThreadId(null) @@ -573,7 +596,7 @@ export default function App({ onEndVoting={endVoting} onStartVoting={startVoting} excalidrawAPI={excalidrawAPI} - isReadOnly={isReadOnly} + isReadOnly={effectiveReadOnly} /> diff --git a/src/components/NetworkStatusIndicator.tsx b/src/components/NetworkStatusIndicator.tsx index 9e7ed089..ed25531d 100644 --- a/src/components/NetworkStatusIndicator.tsx +++ b/src/components/NetworkStatusIndicator.tsx @@ -10,6 +10,7 @@ import { mdiWifiOff, mdiWifi, mdiWifiStrength1, mdiWifiStrength2, mdiLoading } f import { t } from '@nextcloud/l10n' // Import the correct store import { useCollaborationStore } from '../stores/useCollaborationStore' +import { useLocalSyncLeaderStore } from '../stores/useLocalSyncLeaderStore' import type { CollaborationConnectionStatus } from '../stores/useCollaborationStore' interface StatusConfig { @@ -70,6 +71,12 @@ const NetworkStatusIndicatorComponent = () => { authError: state.authError, })), ) + const { isLocalLeader, isPassiveFollower } = useLocalSyncLeaderStore( + useShallow(state => ({ + isLocalLeader: state.isLocalLeader, + isPassiveFollower: state.isPassiveFollower, + })), + ) const [expanded, setExpanded] = useState(false) // Refs to track previous status to avoid unnecessary effects @@ -92,10 +99,31 @@ const NetworkStatusIndicatorComponent = () => { // Memoize status config to prevent recalculation on every render const statusConfig = useMemo(() => getStatusConfig(status), [status]) const { icon, text, className, description } = statusConfig + const leadershipStatus = useMemo(() => { + if (isPassiveFollower) { + return { + text: t('whiteboard', 'Passive tab'), + className: 'network-status--passive', + description: t('whiteboard', 'Another tab is handling sync and editing for this board.'), + } + } + + if (isLocalLeader) { + return { + text: t('whiteboard', 'Active sync tab'), + className: 'network-status--leader', + description: t('whiteboard', 'This tab is handling sync and authoritative collaboration traffic.'), + } + } + + return null + }, [isLocalLeader, isPassiveFollower]) // Enhanced description with auth error context const enhancedDescription = useMemo(() => { - let baseDescription = description + let baseDescription = status === 'online' && leadershipStatus + ? leadershipStatus.description + : description // Add auth error context if there's a persistent auth issue if (authError.isPersistent && authError.type === 'jwt_secret_mismatch') { @@ -105,7 +133,11 @@ const NetworkStatusIndicatorComponent = () => { } return baseDescription - }, [description, authError]) + }, [authError, description, leadershipStatus, status]) + + const displayedText = status === 'online' && leadershipStatus ? leadershipStatus.text : text + const displayedClassName = status === 'online' && leadershipStatus ? leadershipStatus.className : className + const rootClassName = status === 'online' && leadershipStatus ? 'sync-tab-status' : 'network-status' const toggleExpanded = useCallback(() => { setExpanded(prev => !prev) @@ -119,17 +151,17 @@ const NetworkStatusIndicatorComponent = () => { }, [toggleExpanded]) // Hide the indicator when status is online - if (status === 'online') return null + if (status === 'online' && !leadershipStatus) return null return (
@@ -141,7 +173,7 @@ const NetworkStatusIndicatorComponent = () => { {/* Show text only when expanded */} {expanded && (
- {text} + {displayedText}
)}
diff --git a/src/components/VotingSidebar.tsx b/src/components/VotingSidebar.tsx index ae585531..3787ba1f 100644 --- a/src/components/VotingSidebar.tsx +++ b/src/components/VotingSidebar.tsx @@ -30,7 +30,7 @@ export function VotingSidebar({ votings, onVote, onEndVoting, onStartVoting, exc const isOpen = (voting: Voting) => voting.state === 'open' const hasVotedInVoting = (voting: Voting) => voting.options.some(hasVoted) const canVote = (voting: Voting) => { - if (!currentUserId || !isOpen(voting)) return false + if (isReadOnly || !currentUserId || !isOpen(voting)) return false // For single-choice, can't vote if already voted if (voting.type === 'single-choice' && hasVotedInVoting(voting)) return false // For multiple-choice, can always vote (on options not yet voted for) @@ -44,10 +44,18 @@ export function VotingSidebar({ votings, onVote, onEndVoting, onStartVoting, exc } const handleVote = (voting: Voting, option: VotingOption) => { + if (isReadOnly) { + return + } + if (isOpen(voting)) onVote(voting.uuid, option.uuid) } const handleEndVoting = (voting: Voting) => { + if (isReadOnly) { + return + } + if (isOpen(voting)) onEndVoting(voting.uuid) } @@ -68,6 +76,11 @@ export function VotingSidebar({ votings, onVote, onEndVoting, onStartVoting, exc } const addResultAsElements = (voting: Voting) => { + if (isReadOnly) { + showError(t('whiteboard', 'This tab is currently view-only')) + return + } + if (!excalidrawAPI) { showError(t('whiteboard', 'Canvas not ready. Please try again.')) return @@ -250,6 +263,10 @@ export function VotingSidebar({ votings, onVote, onEndVoting, onStartVoting, exc } const handleStartVoting = () => { + if (isReadOnly) { + return + } + spawnDialog(VotingModal, { onStartVoting, }, () => {}) @@ -272,14 +289,14 @@ export function VotingSidebar({ votings, onVote, onEndVoting, onStartVoting, exc

{voting.question}

- {isAuthor(voting) && isOpen(voting) && ( + {!isReadOnly && isAuthor(voting) && isOpen(voting) && ( )} - {!isOpen(voting) && ( + {!isReadOnly && !isOpen(voting) && (