From 996f27133cf3021553aa97a650f45b11e65d4ab9 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 27 Oct 2026 14:57:30 +0100 Subject: [PATCH] fix(collab): fix self-lockout, stuck live sessions, block locking propagation and reactive hashtags --- .../src/features/workspace/DocumentEditor.tsx | 185 +++++++++++++----- .../src/features/workspace/OutlinerTree.tsx | 2 +- .../src/features/workspace/WorkspaceView.tsx | 116 ++++++++--- frontend/src/lib/api.ts | 33 +++- internal/api/server.go | 45 ++++- internal/ws/hub.go | 11 ++ 6 files changed, 303 insertions(+), 89 deletions(-) diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 6f81208..1fdf219 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -14,9 +14,10 @@ import { Minimize2, Sparkles, Plus, + AlertTriangle, } from 'lucide-react'; import { Document, DocumentBlock, User, BlockLockInfo, LiveSessionInfo, WSMessage } from '../../lib/types'; -import { api, getToken, onWebSocketMessage } from '../../lib/api'; +import { api, getToken, getCurrentUserId, createRealtimeConnection } from '../../lib/api'; import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus, getUserColor } from '../../lib/useDocumentSync'; import { OutlinerTree } from './OutlinerTree'; @@ -229,6 +230,11 @@ export const DocumentEditor: React.FC = ({ return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId); }, [title, doc?.title, blocks, zoomedBlockId]); + // Reliable current user ID from prop or JWT token + const currentUserId = useMemo(() => { + return currentUser?.id || getCurrentUserId() || ''; + }, [currentUser?.id]); + // Crucial fix: Collaborator with write permission can edit! const canEdit = useMemo(() => { if (!doc) return true; @@ -237,9 +243,19 @@ export const DocumentEditor: React.FC = ({ }, [doc]); // Is this document completely locked because another user has an active live session? - const isLockedByOtherLive = Boolean(liveSession && liveSession.initiatorId !== currentUser?.id); + const isLockedByOtherLive = Boolean( + liveSession && + liveSession.initiatorId && + currentUserId && + liveSession.initiatorId !== currentUserId + ); const effectiveCanEdit = canEdit && !isLockedByOtherLive; + const isLiveInitiatorOrOwner = Boolean( + (liveSession && currentUserId && liveSession.initiatorId === currentUserId) || + (doc && (doc.isOwner || doc.permission === 'owner')) + ); + // Handle incoming WebSocket events for THIS document room const handleRemoteEvent = useCallback( (evt: DocSyncEvent) => { @@ -524,23 +540,58 @@ export const DocumentEditor: React.FC = ({ onLiveStateChange?.(computedLiveState, liveSession?.initiatorName); }, [computedLiveState, liveSession?.initiatorName, onLiveStateChange]); - const handleToggleLive = useCallback(() => { - if (isLiveEnabled) { - api.endLiveSession(docId).catch(() => {}); + const isLiveEnabledRef = useRef(isLiveEnabled); + isLiveEnabledRef.current = isLiveEnabled; + + const handleToggleLive = useCallback(async () => { + if (isLiveEnabled || isLiveInitiatorOrOwner) { + try { + await api.endLiveSession(docId); + } catch {} setIsLiveEnabled(false); setLiveSession(null); } else { if (isLockedByOtherLive) return; - api.startLiveSession(docId) - .then((session) => { - setLiveSession(session); - setIsLiveEnabled(true); - }) - .catch((err) => { - console.error('[DocumentEditor] Failed to start live session:', err); - }); + try { + const session = await api.startLiveSession(docId); + setLiveSession(session); + setIsLiveEnabled(true); + } catch (err) { + console.error('[DocumentEditor] Failed to start live session:', err); + } } - }, [docId, isLiveEnabled, isLockedByOtherLive]); + }, [docId, isLiveEnabled, isLiveInitiatorOrOwner, isLockedByOtherLive]); + + // Keep live session lease alive while isLiveEnabled + useEffect(() => { + if (!isLiveEnabled || !docId) return; + const interval = window.setInterval(() => { + api.startLiveSession(docId).catch(() => {}); + }, 20000); + return () => window.clearInterval(interval); + }, [isLiveEnabled, docId]); + + // Cleanup live session on page unload + useEffect(() => { + const handleUnloadLive = () => { + if (isLiveEnabledRef.current) { + const token = getToken(); + if (token) { + fetch(`/api/v1/documents/${docId}/live/end`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + keepalive: true, + }).catch(() => {}); + } + } + }; + window.addEventListener('beforeunload', handleUnloadLive); + window.addEventListener('pagehide', handleUnloadLive); + return () => { + window.removeEventListener('beforeunload', handleUnloadLive); + window.removeEventListener('pagehide', handleUnloadLive); + }; + }, [docId]); useEffect(() => { if (registerLiveToggle) { @@ -556,7 +607,7 @@ export const DocumentEditor: React.FC = ({ if (cancelled) return; if (res.liveSession) { setLiveSession(res.liveSession); - if (res.liveSession.initiatorId === currentUser?.id) { + if (currentUserId && res.liveSession.initiatorId === currentUserId) { setIsLiveEnabled(true); } } @@ -573,36 +624,51 @@ export const DocumentEditor: React.FC = ({ return () => { cancelled = true; }; - }, [docId, currentUser?.id]); + }, [docId, currentUserId]); // Subscribe to realtime lock & live session events useEffect(() => { - const unsub = onWebSocketMessage((msg: WSMessage) => { - if (msg.event === 'DOC_BLOCK_LOCKED' && msg.payload?.docId === docId) { - setActiveBlockLocks((prev) => ({ - ...prev, - [msg.payload.blockId]: msg.payload, - })); - } else if (msg.event === 'DOC_BLOCK_UNLOCKED' && msg.payload?.docId === docId) { - setActiveBlockLocks((prev) => { - const next = { ...prev }; - delete next[msg.payload.blockId]; - return next; - }); - } else if (msg.event === 'DOC_LIVE_STARTED' && msg.payload?.docId === docId) { - setLiveSession(msg.payload); - } else if (msg.event === 'DOC_LIVE_ENDED' && msg.payload?.docId === docId) { - setLiveSession(null); - if (liveSession?.initiatorId === currentUser?.id) { - setIsLiveEnabled(false); + const conn = createRealtimeConnection({ + docId, + onMessage: (msg: WSMessage) => { + if (msg.event === 'DOC_BLOCK_LOCKED') { + const p = (msg.payload?.lock || msg.payload) as BlockLockInfo; + if (p?.docId === docId && p?.blockId) { + setActiveBlockLocks((prev) => ({ + ...prev, + [p.blockId]: p, + })); + } + } else if (msg.event === 'DOC_BLOCK_UNLOCKED') { + const p = msg.payload; + if (p?.docId === docId && p?.blockId) { + setActiveBlockLocks((prev) => { + const next = { ...prev }; + delete next[p.blockId]; + return next; + }); + } + } else if (msg.event === 'DOC_LIVE_STARTED') { + const s = (msg.payload?.session || msg.payload) as LiveSessionInfo; + if (s?.docId === docId) { + setLiveSession(s); + if (currentUserId && s.initiatorId === currentUserId) { + setIsLiveEnabled(true); + } + } + } else if (msg.event === 'DOC_LIVE_ENDED') { + if (msg.payload?.docId === docId) { + setLiveSession(null); + setIsLiveEnabled(false); + } } - } + }, }); return () => { - unsub(); + conn.close(); }; - }, [docId, currentUser?.id, liveSession?.initiatorId]); + }, [docId, currentUserId]); // Block focus/blur locks const localUserColor = useMemo( @@ -1127,11 +1193,42 @@ export const DocumentEditor: React.FC = ({ {/* Live Session Active Banner (when another collaborator is live) */} {isLockedByOtherLive && ( -
- - - Live-Sitzung aktiv (Bearbeitet von {liveSession?.initiatorName || 'anderer Nutzer'}) – Dokument schreibgeschützt, um Versionskonflikte zu vermeiden. - +
+
+ + + Live-Sitzung aktiv (Bearbeitet von {liveSession?.initiatorName || 'anderer Nutzer'}) – Dokument schreibgeschützt, um Versionskonflikte zu vermeiden. + +
+ {isLiveInitiatorOrOwner && ( + + )} +
+ )} + + {/* WebSocket Blocked Banner (when current user has live enabled but WS fails in corporate network) */} + {isLiveEnabled && syncStatus === 'blocked' && ( +
+
+ + + Live-Zusammenarbeit nicht möglich (WebSockets im Firmennetzwerk blockiert). HTTPS Auto-Save ist aktiv. + +
+
)} @@ -1235,7 +1332,7 @@ export const DocumentEditor: React.FC = ({ activeBlockLocks={activeBlockLocks} onFocusBlock={handleFocusBlock} onBlurBlock={handleBlurBlock} - currentUserId={currentUser?.id} + currentUserId={currentUserId} /> )}
@@ -1303,7 +1400,7 @@ export const DocumentEditor: React.FC = ({ activeBlockLocks={activeBlockLocks} onFocusBlock={handleFocusBlock} onBlurBlock={handleBlurBlock} - currentUserId={currentUser?.id} + currentUserId={currentUserId} /> )} diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index d292226..94a9cff 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -329,7 +329,7 @@ export const OutlinerTree: React.FC = ({
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => { const lockInfo = activeBlockLocks?.[block.id]; - const isLockedByOther = Boolean(lockInfo && lockInfo.userId !== currentUserId); + const isLockedByOther = Boolean(lockInfo && currentUserId && lockInfo.userId !== currentUserId); return ( = ({ const [docs, setDocs] = useState([]); const [folders, setFolders] = useState([]); const [sharedFolders, setSharedFolders] = useState([]); - const [tagsSummary, setTagsSummary] = useState([]); + const [serverTagsSummary, setServerTagsSummary] = useState([]); + + // Dynamically compute hashtag counts directly from in-memory documents + const tagsSummary = useMemo(() => { + if (docs.length === 0) return serverTagsSummary; + const tagMap = new Map(); + for (const d of docs) { + for (const b of d.blocks || []) { + const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g); + if (matches) { + for (const m of matches) { + const clean = m.slice(1).toLowerCase(); + tagMap.set(clean, (tagMap.get(clean) || 0) + 1); + } + } + } + } + const list: TagSummary[] = []; + tagMap.forEach((count, tag) => { + list.push({ tag, count }); + }); + return list.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); + }, [docs, serverTagsSummary]); const [selectedDocId, setSelectedDocId] = useState(initialDocId || null); const [targetBlockId, setTargetBlockId] = useState(null); @@ -356,7 +378,7 @@ export const WorkspaceView: React.FC = ({ setDocs(docsRes); setFolders(foldersRes); setSharedFolders(sharedFoldersRes); - setTagsSummary(tagsRes); + setServerTagsSummary(tagsRes); setSelectedDocId((prevId) => { if (prevId && docsRes.some((d) => d.id === prevId)) { @@ -1196,32 +1218,64 @@ export const WorkspaceView: React.FC = ({ {/* Interactive Live Collaboration Button (replaces passive radio icon) */} {docLiveState === 'locked_by_other' ? ( -
- - Live: {docLiveInitiator || 'Gesperrt'} +
+
+ + Live: {docLiveInitiator || 'Gesperrt'} +
+ {(selectedDocObj?.isOwner || selectedDocObj?.permission === 'owner') && ( + + )}
) : docLiveState === 'blocked' ? ( - +
+ + +
) : docLiveState === 'connecting' ? ( - +
+ + +
) : docLiveState === 'active' ? (
-
+
+