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
+256 -42

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,
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) => ({ setActiveBlockLocks((prev) => ({
...prev, ...prev,
[msg.payload.blockId]: msg.payload, [p.blockId]: p,
})); }));
} else if (msg.event === 'DOC_BLOCK_UNLOCKED' && msg.payload?.docId === docId) { }
} else if (msg.event === 'DOC_BLOCK_UNLOCKED') {
const p = msg.payload;
if (p?.docId === docId && p?.blockId) {
setActiveBlockLocks((prev) => { setActiveBlockLocks((prev) => {
const next = { ...prev }; const next = { ...prev };
delete next[msg.payload.blockId]; delete next[p.blockId];
return next; return next;
}); });
} else if (msg.event === 'DOC_LIVE_STARTED' && msg.payload?.docId === docId) { }
setLiveSession(msg.payload); } else if (msg.event === 'DOC_LIVE_STARTED') {
} else if (msg.event === 'DOC_LIVE_ENDED' && msg.payload?.docId === docId) { 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); setLiveSession(null);
if (liveSession?.initiatorId === currentUser?.id) {
setIsLiveEnabled(false); 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,12 +1193,43 @@ 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">
<div className="flex items-center gap-2 min-w-0">
<Lock className="w-4 h-4 shrink-0" /> <Lock className="w-4 h-4 shrink-0" />
<span> <span>
Live-Sitzung aktiv (Bearbeitet von <strong>{liveSession?.initiatorName || 'anderer Nutzer'}</strong>) – Dokument schreibgeschützt, um Versionskonflikte zu vermeiden. Live-Sitzung aktiv (Bearbeitet von <strong>{liveSession?.initiatorName || 'anderer Nutzer'}</strong>) – Dokument schreibgeschützt, um Versionskonflikte zu vermeiden.
</span> </span>
</div> </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>
)} )}
{/* Main Content Area */} {/* Main Content Area */}
@@ -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,6 +1218,7 @@ 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 className="inline-flex items-center gap-1.5">
<div <div
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" 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'}`} title={`Live-Zusammenarbeit aktiv durch ${docLiveInitiator || 'anderer Nutzer'}`}
@@ -1203,7 +1226,19 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<Lock className="w-3.5 h-3.5 shrink-0" /> <Lock className="w-3.5 h-3.5 shrink-0" />
<span className="truncate max-w-[120px]">Live: {docLiveInitiator || 'Gesperrt'}</span> <span className="truncate max-w-[120px]">Live: {docLiveInitiator || 'Gesperrt'}</span>
</div> </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>
) : docLiveState === 'blocked' ? ( ) : docLiveState === 'blocked' ? (
<div className="inline-flex items-center gap-1.5">
<button <button
type="button" type="button"
onClick={() => setShowWsBlockedModal(true)} onClick={() => setShowWsBlockedModal(true)}
@@ -1213,7 +1248,17 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<AlertTriangle className="w-3.5 h-3.5 text-status-err animate-pulse shrink-0" /> <AlertTriangle className="w-3.5 h-3.5 text-status-err animate-pulse shrink-0" />
<span>Live blockiert</span> <span>Live blockiert</span>
</button> </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' ? (
<div className="inline-flex items-center gap-1.5">
<button <button
type="button" type="button"
disabled disabled
@@ -1222,6 +1267,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<Loader2 className="w-3.5 h-3.5 animate-spin text-amber-500 shrink-0" /> <Loader2 className="w-3.5 h-3.5 animate-spin text-amber-500 shrink-0" />
<span>Verbinde…</span> <span>Verbinde…</span>
</button> </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)}
+21 -8
View File
@@ -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: 'CRDT_JOIN', docId: r.slice(4) });
} else {
sendWebSocketMessage({ event: 'JOIN_ROOM', room: r }); sendWebSocketMessage({ event: 'JOIN_ROOM', room: r });
if (activeSSEId) {
api.joinSSERoom(activeSSEId, r, 'join').catch(() => {});
} }
}); });
}; };
@@ -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: 'CRDT_LEAVE', docId: r.slice(4) });
} else {
sendWebSocketMessage({ event: 'LEAVE_ROOM', room: r }); sendWebSocketMessage({ event: 'LEAVE_ROOM', room: r });
if (activeSSEId) {
api.joinSSERoom(activeSSEId, r, 'leave').catch(() => {});
} }
}); });
}, },
+32 -3
View File
@@ -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 {
@@ -1551,6 +1568,11 @@ 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 {
if isOwner {
s.wsHub.ForceEndLiveSession(docID)
} else {
s.wsHub.EndLiveSession(docID, userID) s.wsHub.EndLiveSession(docID, userID)
}
evt := map[string]string{ evt := map[string]string{
"docId": docID, "docId": docID,
"userId": userID, "userId": userID,
+11
View File
@@ -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()