Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 30 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -101,13 +102,15 @@ export default function App({

const {
setConfig,
effectiveReadOnly,
gridModeEnabled,
initialDataPromise,
resetInitialDataPromise,
resetStore,
setGridModeEnabled,
} = useWhiteboardConfigStore(useShallow(state => ({
setConfig: state.setConfig,
effectiveReadOnly: selectEffectiveReadOnly(state),
gridModeEnabled: state.gridModeEnabled,
initialDataPromise: state.initialDataPromise,
resetInitialDataPromise: state.resetInitialDataPromise,
Expand All @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -511,6 +515,25 @@ export default function App({
<div className="excalidraw-wrapper" style={{ flex: 1, height: '100%', position: 'relative' }}>
{!isVersionPreview && <MemoizedNetworkStatusIndicator />}
<MemoizedAuthErrorNotification />
{isPassiveFollower && !isVersionPreview && (
<div
style={{
position: 'absolute',
top: 16,
left: 16,
zIndex: 10002,
padding: '8px 12px',
borderRadius: 8,
background: 'rgba(31, 41, 55, 0.88)',
color: '#fff',
fontSize: 13,
fontWeight: 600,
boxShadow: '0 8px 24px rgba(15, 23, 42, 0.18)',
}}
>
{t('whiteboard', 'Another tab is the active editor for this board.')}
</div>
)}
{isVersionPreview && (
<VersionPreviewBanner
versionLabel={versionLabel}
Expand All @@ -529,7 +552,7 @@ export default function App({
generateIdForFile={generateIdForFile}
onPointerUpdate={onPointerUpdate}
onChange={handleOnChange}
viewModeEnabled={isReadOnly}
viewModeEnabled={effectiveReadOnly}
gridModeEnabled={gridModeEnabled}
theme={theme}
name={fileNameWithoutExtension}
Expand All @@ -551,7 +574,7 @@ export default function App({
<CommentSidebar
threads={commentThreads}
activeThreadId={activeCommentThreadId}
isReadOnly={isReadOnly}
isReadOnly={effectiveReadOnly}
onThreadClick={panToThread}
onDeleteThread={(threadId) => {
activeCommentThreadId === threadId && setActiveCommentThreadId(null)
Expand All @@ -573,7 +596,7 @@ export default function App({
onEndVoting={endVoting}
onStartVoting={startVoting}
excalidrawAPI={excalidrawAPI}
isReadOnly={isReadOnly}
isReadOnly={effectiveReadOnly}
/>
</Sidebar.Tab>
</Sidebar.Tabs>
Expand Down
44 changes: 38 additions & 6 deletions src/components/NetworkStatusIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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') {
Expand All @@ -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)
Expand All @@ -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 (
<div
className={`network-status ${className} ${expanded ? 'network-status--expanded' : ''}`}
className={`${rootClassName} ${displayedClassName} ${expanded ? 'network-status--expanded' : ''}`}
onClick={toggleExpanded}
onKeyDown={handleKeyDown}
title={enhancedDescription} // Tooltip shows detailed info
role="button" // More appropriate role than status if clickable
aria-live="polite" // Announce changes politely
aria-label={`Connection: ${text}. ${expanded ? enhancedDescription : 'Click to expand.'}`} // Dynamic label
aria-label={`Connection: ${displayedText}. ${expanded ? enhancedDescription : 'Click to expand.'}`} // Dynamic label
tabIndex={0} // Make focusable
>
<div className="network-status__icon-container">
Expand All @@ -141,7 +173,7 @@ const NetworkStatusIndicatorComponent = () => {
{/* Show text only when expanded */}
{expanded && (
<div className="network-status__content">
<span className="network-status__text">{text}</span>
<span className="network-status__text">{displayedText}</span>
</div>
)}
</div>
Expand Down
23 changes: 20 additions & 3 deletions src/components/VotingSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}

Expand All @@ -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
Expand Down Expand Up @@ -250,6 +263,10 @@ export function VotingSidebar({ votings, onVote, onEndVoting, onStartVoting, exc
}

const handleStartVoting = () => {
if (isReadOnly) {
return
}

spawnDialog(VotingModal, {
onStartVoting,
}, () => {})
Expand All @@ -272,14 +289,14 @@ export function VotingSidebar({ votings, onVote, onEndVoting, onStartVoting, exc
<div key={voting.uuid} className="voting-item">
<h4>{voting.question}</h4>
<div className="voting-actions">
{isAuthor(voting) && isOpen(voting) && (
{!isReadOnly && isAuthor(voting) && isOpen(voting) && (
<button
onClick={() => handleEndVoting(voting)}
className="end-voting-button">
{t('whiteboard', 'End voting')}
</button>
)}
{!isOpen(voting) && (
{!isReadOnly && !isOpen(voting) && (
<button
onClick={() => addResultAsElements(voting)}
className="add-result-button">
Expand Down
10 changes: 7 additions & 3 deletions src/hooks/useBoardDataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

import { useCallback, useEffect, useState, useRef } from 'react'
import { useWhiteboardConfigStore } from '../stores/useWhiteboardConfigStore'
import { selectEffectiveReadOnly, useWhiteboardConfigStore } from '../stores/useWhiteboardConfigStore'
import { useExcalidrawStore } from '../stores/useExcalidrawStore'
import { useJWTStore } from '../stores/useJwtStore'
import { useSyncStore } from '../stores/useSyncStore'
import { useLocalSyncLeaderStore } from '../stores/useLocalSyncLeaderStore'
import { useCollaborationStore } from '../stores/useCollaborationStore'
import { db } from '../database/db'
import { generateUrl } from '@nextcloud/router'
import { useShallow } from 'zustand/react/shallow'
Expand Down Expand Up @@ -316,9 +318,11 @@ export function useBoardDataManager() {
}

const api = useExcalidrawStore.getState().excalidrawAPI
const currentIsReadOnly = useWhiteboardConfigStore.getState().isReadOnly
const currentIsReadOnly = selectEffectiveReadOnly(useWhiteboardConfigStore.getState())
const { isLocalLeader } = useLocalSyncLeaderStore.getState()
const { isDedicatedSyncer } = useCollaborationStore.getState()

if (api && !currentIsReadOnly) {
if (api && !currentIsReadOnly && isLocalLeader && isDedicatedSyncer) {

const currentFileId = useWhiteboardConfigStore.getState().fileId
const currentWorker = useSyncStore.getState().worker
Expand Down
Loading
Loading