fix(collab): fix self-lockout, stuck live sessions, block locking propagation and reactive hashtags
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s

This commit is contained in:
sandrodach committed 2026-10-27 14:57:30 +01:00
1 parent 0e06cc5577
commit 996f27133c
6 files changed
+303 -89

No files matched your search

@@ -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<DocumentEditorProps> = ({
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<DocumentEditorProps> = ({
}, [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<DocumentEditorProps> = ({
onLiveStateChange?.(computedLiveState, liveSession?.initiatorName);
}, [computedLiveState, liveSession?.initiatorName, onLiveStateChange]);
const handleToggleLive = useCallback(() => {
if (isLiveEnabled) {
api.endLiveSession(docId).catch(() => {});
const isLiveEnabledRef = useRef<boolean>(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<DocumentEditorProps> = ({
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<DocumentEditorProps> = ({
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<DocumentEditorProps> = ({
{/* Live Session Active Banner (when another collaborator is live) */}
{isLockedByOtherLive && (
<div className="flex items-center gap-2 px-4 sm:px-6 py-2.5 bg-status-warn/15 border-b border-status-warn/30 text-status-warn text-xs font-medium">
<Lock className="w-4 h-4 shrink-0" />
<span>
Live-Sitzung aktiv (Bearbeitet von <strong>{liveSession?.initiatorName || 'anderer Nutzer'}</strong>) – Dokument schreibgeschützt, um Versionskonflikte zu vermeiden.
</span>
<div className="flex items-center justify-between gap-3 px-4 sm:px-6 py-2.5 bg-status-warn/15 border-b border-status-warn/30 text-status-warn text-xs font-medium">
<div className="flex items-center gap-2 min-w-0">
<Lock className="w-4 h-4 shrink-0" />
<span>
Live-Sitzung aktiv (Bearbeitet von <strong>{liveSession?.initiatorName || 'anderer Nutzer'}</strong>) – Dokument schreibgeschützt, um Versionskonflikte zu vermeiden.
</span>
</div>
{isLiveInitiatorOrOwner && (
<button
type="button"
onClick={handleToggleLive}
className="px-2.5 py-1 text-xs font-semibold rounded-btn bg-status-warn/25 border border-status-warn/40 hover:bg-status-warn/35 text-status-warn shrink-0 cursor-pointer transition-colors"
title="Live-Sitzung als Notiz-Eigentümer/Initiator beenden"
>
Sitzung beenden
</button>
)}
</div>
)}
{/* WebSocket Blocked Banner (when current user has live enabled but WS fails in corporate network) */}
{isLiveEnabled && syncStatus === 'blocked' && (
<div className="flex items-center justify-between gap-3 px-4 sm:px-6 py-2.5 bg-status-err/15 border-b border-status-err/30 text-status-err text-xs font-medium">
<div className="flex items-center gap-2 min-w-0">
<AlertTriangle className="w-4 h-4 shrink-0 text-status-err" />
<span>
Live-Zusammenarbeit nicht möglich (WebSockets im Firmennetzwerk blockiert). HTTPS Auto-Save ist aktiv.
</span>
</div>
<button
type="button"
onClick={handleToggleLive}
className="px-2.5 py-1 text-xs font-semibold rounded-btn bg-status-err/25 border border-status-err/40 hover:bg-status-err/35 text-status-err shrink-0 cursor-pointer transition-colors"
>
Live deaktivieren
</button>
</div>
)}
@@ -1235,7 +1332,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
activeBlockLocks={activeBlockLocks}
onFocusBlock={handleFocusBlock}
onBlurBlock={handleBlurBlock}
currentUserId={currentUser?.id}
currentUserId={currentUserId}
/>
)}
</div>
@@ -1303,7 +1400,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
activeBlockLocks={activeBlockLocks}
onFocusBlock={handleFocusBlock}
onBlurBlock={handleBlurBlock}
currentUserId={currentUser?.id}
currentUserId={currentUserId}
/>
</>
)}