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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
This commit is contained in:
1 parent
0e06cc5577
commit
996f27133c
6 files changed
+303
-89
No files matched your search
@@ -14,9 +14,10 @@ import {
|
|||||||
Minimize2,
|
Minimize2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Plus,
|
Plus,
|
||||||
|
AlertTriangle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Document, DocumentBlock, User, BlockLockInfo, LiveSessionInfo, WSMessage } from '../../lib/types';
|
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 { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus, getUserColor } from '../../lib/useDocumentSync';
|
||||||
import { OutlinerTree } from './OutlinerTree';
|
import { OutlinerTree } from './OutlinerTree';
|
||||||
|
|
||||||
@@ -229,6 +230,11 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId);
|
return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId);
|
||||||
}, [title, doc?.title, 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!
|
// Crucial fix: Collaborator with write permission can edit!
|
||||||
const canEdit = useMemo(() => {
|
const canEdit = useMemo(() => {
|
||||||
if (!doc) return true;
|
if (!doc) return true;
|
||||||
@@ -237,9 +243,19 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
}, [doc]);
|
}, [doc]);
|
||||||
|
|
||||||
// Is this document completely locked because another user has an active live session?
|
// 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 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
|
// Handle incoming WebSocket events for THIS document room
|
||||||
const handleRemoteEvent = useCallback(
|
const handleRemoteEvent = useCallback(
|
||||||
(evt: DocSyncEvent) => {
|
(evt: DocSyncEvent) => {
|
||||||
@@ -524,23 +540,58 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onLiveStateChange?.(computedLiveState, liveSession?.initiatorName);
|
onLiveStateChange?.(computedLiveState, liveSession?.initiatorName);
|
||||||
}, [computedLiveState, liveSession?.initiatorName, onLiveStateChange]);
|
}, [computedLiveState, liveSession?.initiatorName, onLiveStateChange]);
|
||||||
|
|
||||||
const handleToggleLive = useCallback(() => {
|
const isLiveEnabledRef = useRef<boolean>(isLiveEnabled);
|
||||||
if (isLiveEnabled) {
|
isLiveEnabledRef.current = isLiveEnabled;
|
||||||
api.endLiveSession(docId).catch(() => {});
|
|
||||||
|
const handleToggleLive = useCallback(async () => {
|
||||||
|
if (isLiveEnabled || isLiveInitiatorOrOwner) {
|
||||||
|
try {
|
||||||
|
await api.endLiveSession(docId);
|
||||||
|
} catch {}
|
||||||
setIsLiveEnabled(false);
|
setIsLiveEnabled(false);
|
||||||
setLiveSession(null);
|
setLiveSession(null);
|
||||||
} else {
|
} else {
|
||||||
if (isLockedByOtherLive) return;
|
if (isLockedByOtherLive) return;
|
||||||
api.startLiveSession(docId)
|
try {
|
||||||
.then((session) => {
|
const session = await api.startLiveSession(docId);
|
||||||
setLiveSession(session);
|
setLiveSession(session);
|
||||||
setIsLiveEnabled(true);
|
setIsLiveEnabled(true);
|
||||||
})
|
} catch (err) {
|
||||||
.catch((err) => {
|
console.error('[DocumentEditor] Failed to start live session:', 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(() => {
|
useEffect(() => {
|
||||||
if (registerLiveToggle) {
|
if (registerLiveToggle) {
|
||||||
@@ -556,7 +607,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (res.liveSession) {
|
if (res.liveSession) {
|
||||||
setLiveSession(res.liveSession);
|
setLiveSession(res.liveSession);
|
||||||
if (res.liveSession.initiatorId === currentUser?.id) {
|
if (currentUserId && res.liveSession.initiatorId === currentUserId) {
|
||||||
setIsLiveEnabled(true);
|
setIsLiveEnabled(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -573,36 +624,51 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [docId, currentUser?.id]);
|
}, [docId, currentUserId]);
|
||||||
|
|
||||||
// Subscribe to realtime lock & live session events
|
// Subscribe to realtime lock & live session events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = onWebSocketMessage((msg: WSMessage) => {
|
const conn = createRealtimeConnection({
|
||||||
if (msg.event === 'DOC_BLOCK_LOCKED' && msg.payload?.docId === docId) {
|
docId,
|
||||||
setActiveBlockLocks((prev) => ({
|
onMessage: (msg: WSMessage) => {
|
||||||
...prev,
|
if (msg.event === 'DOC_BLOCK_LOCKED') {
|
||||||
[msg.payload.blockId]: msg.payload,
|
const p = (msg.payload?.lock || msg.payload) as BlockLockInfo;
|
||||||
}));
|
if (p?.docId === docId && p?.blockId) {
|
||||||
} else if (msg.event === 'DOC_BLOCK_UNLOCKED' && msg.payload?.docId === docId) {
|
setActiveBlockLocks((prev) => ({
|
||||||
setActiveBlockLocks((prev) => {
|
...prev,
|
||||||
const next = { ...prev };
|
[p.blockId]: p,
|
||||||
delete next[msg.payload.blockId];
|
}));
|
||||||
return next;
|
}
|
||||||
});
|
} else if (msg.event === 'DOC_BLOCK_UNLOCKED') {
|
||||||
} else if (msg.event === 'DOC_LIVE_STARTED' && msg.payload?.docId === docId) {
|
const p = msg.payload;
|
||||||
setLiveSession(msg.payload);
|
if (p?.docId === docId && p?.blockId) {
|
||||||
} else if (msg.event === 'DOC_LIVE_ENDED' && msg.payload?.docId === docId) {
|
setActiveBlockLocks((prev) => {
|
||||||
setLiveSession(null);
|
const next = { ...prev };
|
||||||
if (liveSession?.initiatorId === currentUser?.id) {
|
delete next[p.blockId];
|
||||||
setIsLiveEnabled(false);
|
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 () => {
|
return () => {
|
||||||
unsub();
|
conn.close();
|
||||||
};
|
};
|
||||||
}, [docId, currentUser?.id, liveSession?.initiatorId]);
|
}, [docId, currentUserId]);
|
||||||
|
|
||||||
// Block focus/blur locks
|
// Block focus/blur locks
|
||||||
const localUserColor = useMemo(
|
const localUserColor = useMemo(
|
||||||
@@ -1127,11 +1193,42 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
|
|
||||||
{/* Live Session Active Banner (when another collaborator is live) */}
|
{/* Live Session Active Banner (when another collaborator is live) */}
|
||||||
{isLockedByOtherLive && (
|
{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">
|
<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">
|
||||||
<Lock className="w-4 h-4 shrink-0" />
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span>
|
<Lock className="w-4 h-4 shrink-0" />
|
||||||
Live-Sitzung aktiv (Bearbeitet von <strong>{liveSession?.initiatorName || 'anderer Nutzer'}</strong>) – Dokument schreibgeschützt, um Versionskonflikte zu vermeiden.
|
<span>
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1235,7 +1332,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
activeBlockLocks={activeBlockLocks}
|
activeBlockLocks={activeBlockLocks}
|
||||||
onFocusBlock={handleFocusBlock}
|
onFocusBlock={handleFocusBlock}
|
||||||
onBlurBlock={handleBlurBlock}
|
onBlurBlock={handleBlurBlock}
|
||||||
currentUserId={currentUser?.id}
|
currentUserId={currentUserId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1303,7 +1400,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
activeBlockLocks={activeBlockLocks}
|
activeBlockLocks={activeBlockLocks}
|
||||||
onFocusBlock={handleFocusBlock}
|
onFocusBlock={handleFocusBlock}
|
||||||
onBlurBlock={handleBlurBlock}
|
onBlurBlock={handleBlurBlock}
|
||||||
currentUserId={currentUser?.id}
|
currentUserId={currentUserId}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => {
|
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => {
|
||||||
const lockInfo = activeBlockLocks?.[block.id];
|
const lockInfo = activeBlockLocks?.[block.id];
|
||||||
const isLockedByOther = Boolean(lockInfo && lockInfo.userId !== currentUserId);
|
const isLockedByOther = Boolean(lockInfo && currentUserId && lockInfo.userId !== currentUserId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OutlinerBlock
|
<OutlinerBlock
|
||||||
|
|||||||
@@ -267,7 +267,29 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const [docs, setDocs] = useState<Document[]>([]);
|
const [docs, setDocs] = useState<Document[]>([]);
|
||||||
const [folders, setFolders] = useState<string[]>([]);
|
const [folders, setFolders] = useState<string[]>([]);
|
||||||
const [sharedFolders, setSharedFolders] = useState<SharedFolder[]>([]);
|
const [sharedFolders, setSharedFolders] = useState<SharedFolder[]>([]);
|
||||||
const [tagsSummary, setTagsSummary] = useState<TagSummary[]>([]);
|
const [serverTagsSummary, setServerTagsSummary] = useState<TagSummary[]>([]);
|
||||||
|
|
||||||
|
// Dynamically compute hashtag counts directly from in-memory documents
|
||||||
|
const tagsSummary = useMemo(() => {
|
||||||
|
if (docs.length === 0) return serverTagsSummary;
|
||||||
|
const tagMap = new Map<string, number>();
|
||||||
|
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<string | null>(initialDocId || null);
|
const [selectedDocId, setSelectedDocId] = useState<string | null>(initialDocId || null);
|
||||||
const [targetBlockId, setTargetBlockId] = useState<string | null>(null);
|
const [targetBlockId, setTargetBlockId] = useState<string | null>(null);
|
||||||
@@ -356,7 +378,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setDocs(docsRes);
|
setDocs(docsRes);
|
||||||
setFolders(foldersRes);
|
setFolders(foldersRes);
|
||||||
setSharedFolders(sharedFoldersRes);
|
setSharedFolders(sharedFoldersRes);
|
||||||
setTagsSummary(tagsRes);
|
setServerTagsSummary(tagsRes);
|
||||||
|
|
||||||
setSelectedDocId((prevId) => {
|
setSelectedDocId((prevId) => {
|
||||||
if (prevId && docsRes.some((d) => d.id === prevId)) {
|
if (prevId && docsRes.some((d) => d.id === prevId)) {
|
||||||
@@ -1196,32 +1218,64 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
{/* Interactive Live Collaboration Button (replaces passive radio icon) */}
|
{/* Interactive Live Collaboration Button (replaces passive radio icon) */}
|
||||||
{docLiveState === 'locked_by_other' ? (
|
{docLiveState === 'locked_by_other' ? (
|
||||||
<div
|
<div className="inline-flex items-center gap-1.5">
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-status-warn/15 border border-status-warn/40 text-status-warn select-none"
|
<div
|
||||||
title={`Live-Zusammenarbeit aktiv durch ${docLiveInitiator || 'anderer Nutzer'}`}
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-status-warn/15 border border-status-warn/40 text-status-warn select-none"
|
||||||
>
|
title={`Live-Zusammenarbeit aktiv durch ${docLiveInitiator || 'anderer Nutzer'}`}
|
||||||
<Lock className="w-3.5 h-3.5 shrink-0" />
|
>
|
||||||
<span className="truncate max-w-[120px]">Live: {docLiveInitiator || 'Gesperrt'}</span>
|
<Lock className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
<span className="truncate max-w-[120px]">Live: {docLiveInitiator || 'Gesperrt'}</span>
|
||||||
|
</div>
|
||||||
|
{(selectedDocObj?.isOwner || selectedDocObj?.permission === 'owner') && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => liveToggleRef.current?.()}
|
||||||
|
className="px-2 py-1 rounded-btn text-xs font-semibold bg-status-warn/20 border border-status-warn/40 text-status-warn hover:bg-status-warn/30 transition-colors cursor-pointer"
|
||||||
|
title="Als Notiz-Besitzer Live-Sitzung beenden und Dokument freigeben"
|
||||||
|
>
|
||||||
|
Freigeben
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : docLiveState === 'blocked' ? (
|
) : docLiveState === 'blocked' ? (
|
||||||
<button
|
<div className="inline-flex items-center gap-1.5">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setShowWsBlockedModal(true)}
|
type="button"
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-status-err/15 border border-status-err/40 text-status-err hover:bg-status-err/25 transition-colors cursor-pointer"
|
onClick={() => setShowWsBlockedModal(true)}
|
||||||
title="WebSockets im Firmennetzwerk blockiert – Hier klicken für Infos"
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-status-err/15 border border-status-err/40 text-status-err hover:bg-status-err/25 transition-colors cursor-pointer"
|
||||||
>
|
title="WebSockets im Firmennetzwerk blockiert – Hier klicken für Infos"
|
||||||
<AlertTriangle className="w-3.5 h-3.5 text-status-err animate-pulse shrink-0" />
|
>
|
||||||
<span>Live blockiert</span>
|
<AlertTriangle className="w-3.5 h-3.5 text-status-err animate-pulse shrink-0" />
|
||||||
</button>
|
<span>Live blockiert</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => liveToggleRef.current?.()}
|
||||||
|
className="px-2 py-1 rounded-btn text-xs font-medium bg-canvas border border-border text-muted hover:text-status-err hover:border-status-err/50 transition-colors cursor-pointer"
|
||||||
|
title="Live-Sitzung beenden"
|
||||||
|
>
|
||||||
|
Beenden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : docLiveState === 'connecting' ? (
|
) : docLiveState === 'connecting' ? (
|
||||||
<button
|
<div className="inline-flex items-center gap-1.5">
|
||||||
type="button"
|
<button
|
||||||
disabled
|
type="button"
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-amber-500/15 border border-amber-500/40 text-amber-500 opacity-80 cursor-wait"
|
disabled
|
||||||
>
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-amber-500/15 border border-amber-500/40 text-amber-500 opacity-80 cursor-wait"
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-amber-500 shrink-0" />
|
>
|
||||||
<span>Verbinde…</span>
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-amber-500 shrink-0" />
|
||||||
</button>
|
<span>Verbinde…</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => liveToggleRef.current?.()}
|
||||||
|
className="px-2 py-1 rounded-btn text-xs font-medium bg-canvas border border-border text-muted hover:text-status-err hover:border-status-err/50 transition-colors cursor-pointer"
|
||||||
|
title="Verbindungsaufbau abbrechen"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : docLiveState === 'active' ? (
|
) : docLiveState === 'active' ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2547,7 +2601,17 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end pt-2">
|
<div className="flex justify-between items-center pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
liveToggleRef.current?.();
|
||||||
|
setShowWsBlockedModal(false);
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium bg-status-err/15 border border-status-err/40 text-status-err hover:bg-status-err/25 rounded-btn transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Live-Sitzung beenden
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowWsBlockedModal(false)}
|
onClick={() => setShowWsBlockedModal(false)}
|
||||||
|
|||||||
+23
-10
@@ -40,6 +40,19 @@ export function getToken(): string | null {
|
|||||||
return localStorage.getItem(TOKEN_STORAGE_KEY);
|
return localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCurrentUserId(): string | null {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const payload = JSON.parse(atob(parts[1]));
|
||||||
|
return payload.userId || payload.sub || payload.id || null;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function setToken(token: string): void {
|
export function setToken(token: string): void {
|
||||||
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||||
}
|
}
|
||||||
@@ -637,10 +650,11 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async lockBlock(docId: string, blockId: string, userColor?: string, ttlSeconds: number = 30): Promise<BlockLockInfo> {
|
async lockBlock(docId: string, blockId: string, userColor?: string, ttlSeconds: number = 30): Promise<BlockLockInfo> {
|
||||||
return request<BlockLockInfo>(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/lock`, {
|
const res = await request<any>(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/lock`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ userColor, ttlSeconds }),
|
body: JSON.stringify({ userColor, ttlSeconds }),
|
||||||
});
|
});
|
||||||
|
return res?.lock || res;
|
||||||
},
|
},
|
||||||
|
|
||||||
async unlockBlock(docId: string, blockId: string): Promise<{ status: string }> {
|
async unlockBlock(docId: string, blockId: string): Promise<{ status: string }> {
|
||||||
@@ -650,9 +664,10 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async startLiveSession(docId: string): Promise<LiveSessionInfo> {
|
async startLiveSession(docId: string): Promise<LiveSessionInfo> {
|
||||||
return request<LiveSessionInfo>(`/api/v1/documents/${docId}/live/start`, {
|
const res = await request<any>(`/api/v1/documents/${docId}/live/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
|
return res?.session || res;
|
||||||
},
|
},
|
||||||
|
|
||||||
async endLiveSession(docId: string): Promise<{ status: string }> {
|
async endLiveSession(docId: string): Promise<{ status: string }> {
|
||||||
@@ -1442,10 +1457,9 @@ export function createRealtimeConnection(options: {
|
|||||||
// Join rooms on the active connection
|
// Join rooms on the active connection
|
||||||
const joinAll = () => {
|
const joinAll = () => {
|
||||||
joinedRooms.forEach((r) => {
|
joinedRooms.forEach((r) => {
|
||||||
if (r.startsWith('doc:')) {
|
sendWebSocketMessage({ event: 'JOIN_ROOM', room: r });
|
||||||
sendWebSocketMessage({ event: 'CRDT_JOIN', docId: r.slice(4) });
|
if (activeSSEId) {
|
||||||
} else {
|
api.joinSSERoom(activeSSEId, r, 'join').catch(() => {});
|
||||||
sendWebSocketMessage({ event: 'JOIN_ROOM', room: r });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1475,10 +1489,9 @@ export function createRealtimeConnection(options: {
|
|||||||
if (unsubMsg) unsubMsg();
|
if (unsubMsg) unsubMsg();
|
||||||
joinedRooms.forEach((r) => {
|
joinedRooms.forEach((r) => {
|
||||||
globalSubscribedRooms.delete(r);
|
globalSubscribedRooms.delete(r);
|
||||||
if (r.startsWith('doc:')) {
|
sendWebSocketMessage({ event: 'LEAVE_ROOM', room: r });
|
||||||
sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: r.slice(4) });
|
if (activeSSEId) {
|
||||||
} else {
|
api.joinSSERoom(activeSSEId, r, 'leave').catch(() => {});
|
||||||
sendWebSocketMessage({ event: 'LEAVE_ROOM', room: r });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
+37
-8
@@ -1490,9 +1490,20 @@ func (s *Server) handleLockBlock(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.wsHub.BroadcastRoom("doc:"+docID, "DOC_BLOCK_LOCKED", lockInfo, nil)
|
s.wsHub.BroadcastRoom("doc:"+docID, "DOC_BLOCK_LOCKED", lockInfo, nil)
|
||||||
|
if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil {
|
||||||
|
for _, uid := range users {
|
||||||
|
s.wsHub.BroadcastToUser(uid, "DOC_BLOCK_LOCKED", lockInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
"lock": lockInfo,
|
"lock": lockInfo,
|
||||||
|
"docId": lockInfo.DocID,
|
||||||
|
"blockId": lockInfo.BlockID,
|
||||||
|
"userId": lockInfo.UserID,
|
||||||
|
"userName": lockInfo.UserName,
|
||||||
|
"userColor": lockInfo.UserColor,
|
||||||
|
"expiresAt": lockInfo.ExpiresAt,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1506,11 +1517,17 @@ func (s *Server) handleUnlockBlock(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if s.wsHub != nil {
|
if s.wsHub != nil {
|
||||||
s.wsHub.ReleaseBlockLock(docID, blockID, userID)
|
s.wsHub.ReleaseBlockLock(docID, blockID, userID)
|
||||||
s.wsHub.BroadcastRoom("doc:"+docID, "DOC_BLOCK_UNLOCKED", map[string]string{
|
evt := map[string]string{
|
||||||
"docId": docID,
|
"docId": docID,
|
||||||
"blockId": blockID,
|
"blockId": blockID,
|
||||||
"userId": userID,
|
"userId": userID,
|
||||||
}, nil)
|
}
|
||||||
|
s.wsHub.BroadcastRoom("doc:"+docID, "DOC_BLOCK_UNLOCKED", evt, nil)
|
||||||
|
if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil {
|
||||||
|
for _, uid := range users {
|
||||||
|
s.wsHub.BroadcastToUser(uid, "DOC_BLOCK_UNLOCKED", evt)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||||
}
|
}
|
||||||
@@ -1529,7 +1546,7 @@ func (s *Server) handleStartLiveSession(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ttl := 120 * time.Second // 2 min lease with heartbeat while active
|
ttl := 60 * time.Second // 1 min lease with heartbeat while active
|
||||||
if s.wsHub != nil {
|
if s.wsHub != nil {
|
||||||
info, ok, existing := s.wsHub.StartLiveSession(docID, userID, userName, ttl)
|
info, ok, existing := s.wsHub.StartLiveSession(docID, userID, userName, ttl)
|
||||||
if !ok && existing != nil {
|
if !ok && existing != nil {
|
||||||
@@ -1549,8 +1566,13 @@ func (s *Server) handleStartLiveSession(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"success": true,
|
"success": true,
|
||||||
"session": info,
|
"session": info,
|
||||||
|
"docId": info.DocID,
|
||||||
|
"initiatorId": info.InitiatorID,
|
||||||
|
"initiatorName": info.InitiatorName,
|
||||||
|
"startedAt": info.StartedAt,
|
||||||
|
"expiresAt": info.ExpiresAt,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1561,8 +1583,15 @@ func (s *Server) handleEndLiveSession(w http.ResponseWriter, r *http.Request) {
|
|||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
docID := chi.URLParam(r, "id")
|
docID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
doc, _ := s.store.GetDocumentByID(userID, docID)
|
||||||
|
isOwner := doc != nil && (doc.IsOwner || doc.Permission == "owner" || doc.UserID == userID)
|
||||||
|
|
||||||
if s.wsHub != nil {
|
if s.wsHub != nil {
|
||||||
s.wsHub.EndLiveSession(docID, userID)
|
if isOwner {
|
||||||
|
s.wsHub.ForceEndLiveSession(docID)
|
||||||
|
} else {
|
||||||
|
s.wsHub.EndLiveSession(docID, userID)
|
||||||
|
}
|
||||||
evt := map[string]string{
|
evt := map[string]string{
|
||||||
"docId": docID,
|
"docId": docID,
|
||||||
"userId": userID,
|
"userId": userID,
|
||||||
|
|||||||
@@ -644,6 +644,17 @@ func (h *Hub) EndLiveSession(docID, userID string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Hub) ForceEndLiveSession(docID string) bool {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := h.liveSessions[docID]; !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(h.liveSessions, docID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Hub) GetLiveSession(docID string) *domain.LiveSessionInfo {
|
func (h *Hub) GetLiveSession(docID string) *domain.LiveSessionInfo {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
|
|||||||
Reference in new issue
Block a user