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) && (