From 920716be25c883e8a8ddbf50a84905f32dff6f49 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 11:21:46 +0100 Subject: [PATCH] fix(workspace): streamline write lock release, sync, and active editor badge --- .../src/features/workspace/WorkspaceView.tsx | 189 ++++++++++++------ internal/api/server.go | 8 +- internal/ws/hub.go | 8 +- 3 files changed, 132 insertions(+), 73 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index dfb308b..1169067 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -47,7 +47,7 @@ import { Share2, } from 'lucide-react'; import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, User } from '../../lib/types'; -import { api, triggerHaptic } from '../../lib/api'; +import { api, getToken, triggerHaptic } from '../../lib/api'; interface WorkspaceViewProps { currentUser?: User | null; @@ -155,6 +155,7 @@ export const WorkspaceView: React.FC = ({ const heartbeatTimerRef = useRef(null); const idleUnlockTimerRef = useRef(null); const currentDocIdRef = useRef(null); + const isEditorFocusedRef = useRef(false); const blockInputRefs = useRef>({}); @@ -181,12 +182,13 @@ export const WorkspaceView: React.FC = ({ setHoldsLock(false); stopHeartbeat(); } - }, 10000); + }, 4000); }; const releaseMyLock = async (docId?: string) => { const targetDocId = docId || currentDocIdRef.current; - if (!targetDocId || !holdsLockRef.current) return; + if (!targetDocId) return; + const wasHolding = holdsLockRef.current; stopHeartbeat(); if (idleUnlockTimerRef.current) { clearTimeout(idleUnlockTimerRef.current); @@ -194,24 +196,16 @@ export const WorkspaceView: React.FC = ({ } holdsLockRef.current = false; setHoldsLock(false); - try { - await api.unlockDocument(targetDocId); - } catch {} - }; - - const resetIdleTimer = () => { - if (idleUnlockTimerRef.current) { - clearTimeout(idleUnlockTimerRef.current); + if (wasHolding || docId) { + try { + await api.unlockDocument(targetDocId); + } catch {} } - idleUnlockTimerRef.current = setTimeout(() => { - releaseMyLock(); - }, 25000); }; const ensureLock = async (): Promise => { if (!selectedDoc || selectedDoc.permission === 'read') return false; if (holdsLockRef.current) { - resetIdleTimer(); return true; } try { @@ -221,7 +215,6 @@ export const WorkspaceView: React.FC = ({ setHoldsLock(true); setActiveLock(null); startHeartbeat(selectedDoc.id); - resetIdleTimer(); return true; } else if (res.lock) { setActiveLock(res.lock); @@ -235,6 +228,32 @@ export const WorkspaceView: React.FC = ({ return false; }; + const handleBlockFocus = () => { + isEditorFocusedRef.current = true; + if (idleUnlockTimerRef.current) { + clearTimeout(idleUnlockTimerRef.current); + idleUnlockTimerRef.current = null; + } + if (!isReadOnly) { + ensureLock(); + } + }; + + const handleBlockBlur = () => { + isEditorFocusedRef.current = false; + if (idleUnlockTimerRef.current) { + clearTimeout(idleUnlockTimerRef.current); + } + // Short grace period: if user clicked into another block in the doc, focus will cancel this. + // If user clicked outside or stopped editing, save and release the lock immediately! + idleUnlockTimerRef.current = setTimeout(async () => { + if (!isEditorFocusedRef.current && holdsLockRef.current) { + await flushPendingSave(); + await releaseMyLock(); + } + }, 1500); + }; + // Debounced auto-save refs (500ms delay to avoid writing every keystroke to SSD) const debounceSaveTimerRef = useRef(null); const pendingSaveDocRef = useRef(null); @@ -274,6 +293,9 @@ export const WorkspaceView: React.FC = ({ pendingSaveDocRef.current = null; setSaveState('saved'); } + if (!isEditorFocusedRef.current && holdsLockRef.current) { + releaseMyLock(); + } } }; @@ -342,6 +364,11 @@ export const WorkspaceView: React.FC = ({ loadDocs(initialDocId); }, [initialDocId]); + const selectedDocIdRef = useRef(null); + selectedDocIdRef.current = selectedDoc?.id || null; + const currentUserIdRef = useRef(null); + currentUserIdRef.current = currentUser?.id || null; + // Handle document switch and lock status checks useEffect(() => { const prevId = currentDocIdRef.current; @@ -374,13 +401,36 @@ export const WorkspaceView: React.FC = ({ }; }, [selectedDoc?.id]); + // Window blur / visibility change listener: release lock when user leaves tab/window + useEffect(() => { + const handleWindowBlur = () => { + if (!isEditorFocusedRef.current && holdsLockRef.current) { + flushPendingSave(); + releaseMyLock(); + } + }; + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + isEditorFocusedRef.current = false; + flushPendingSave(); + releaseMyLock(); + } + }; + window.addEventListener('blur', handleWindowBlur); + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + window.removeEventListener('blur', handleWindowBlur); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, []); + // Flush pending save and release lock before tab closes or unmounts useEffect(() => { const handleBeforeUnload = () => { flushPendingSave(); if (currentDocIdRef.current && holdsLockRef.current) { try { - const token = localStorage.getItem('mindsift_token') || sessionStorage.getItem('mindsift_token'); + const token = getToken(); fetch(`/api/v1/documents/${currentDocIdRef.current}/lock`, { method: 'DELETE', headers: token ? { Authorization: `Bearer ${token}` } : {}, @@ -407,16 +457,23 @@ export const WorkspaceView: React.FC = ({ if (msg.event === 'DOC_LOCKED') { const payload = msg.payload as DocLockInfo; - if (payload && payload.docId === selectedDoc?.id) { - if (payload.userId !== currentUser?.id) { + if (payload && payload.docId === selectedDocIdRef.current) { + if (payload.userId !== currentUserIdRef.current) { setActiveLock(payload); } } } else if (msg.event === 'DOC_UNLOCKED') { const payload = msg.payload as { docId?: string; doc_id?: string; id?: string }; const unlockedDocId = payload?.docId || payload?.doc_id || payload?.id; - if (unlockedDocId && unlockedDocId === selectedDoc?.id) { + if (unlockedDocId && unlockedDocId === selectedDocIdRef.current) { setActiveLock(null); + // Fetch fresh document from backend so updates made by the other user are immediately visible + api.getDocument(unlockedDocId).then((freshDoc) => { + if (freshDoc && freshDoc.id === selectedDocIdRef.current) { + setSelectedDoc((prev) => (prev && prev.id === freshDoc.id ? { ...prev, ...freshDoc, isPinned: prev.isPinned } : prev)); + setDocs((prev) => prev.map((d) => (d.id === freshDoc.id ? { ...d, ...freshDoc, isPinned: d.isPinned } : d))); + } + }).catch(() => {}); } } else if (msg.event === 'DOC_CREATED') { const created = msg.payload as Document; @@ -430,7 +487,7 @@ export const WorkspaceView: React.FC = ({ setDocs((prev) => prev.map((d) => (d.id === updated.id ? { ...d, ...updated, isPinned: d.isPinned } : d)) ); - if (selectedDoc?.id === updated.id) { + if (selectedDocIdRef.current === updated.id) { if (!holdsLockRef.current) { setSelectedDoc((prev) => (prev ? { ...prev, ...updated, isPinned: prev.isPinned } : updated)); } @@ -441,7 +498,7 @@ export const WorkspaceView: React.FC = ({ const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id; if (deletedId) { setDocs((prev) => prev.filter((d) => d.id !== deletedId)); - if (selectedDoc?.id === deletedId) { + if (selectedDocIdRef.current === deletedId) { setSelectedDoc(null); } onStatsChange(); @@ -451,7 +508,7 @@ export const WorkspaceView: React.FC = ({ window.addEventListener('mindsift:ws', handleWs); return () => window.removeEventListener('mindsift:ws', handleWs); - }, [selectedDoc?.id, currentUser?.id, onStatsChange]); + }, [onStatsChange]); const openShareModal = async () => { if (!selectedDoc) return; @@ -1904,14 +1961,32 @@ export const WorkspaceView: React.FC = ({ {selectedDoc.isOwner === false && ( - {selectedDoc.permission === 'read' ? 'Nur Lesezugriff' : 'Schreibzugriff'} - {selectedDoc.ownerName ? ` (von ${selectedDoc.ownerName})` : ' (Geteilt)'} + + Geteilt von {selectedDoc.ownerName || 'Eigentümer'} + + )} + + {selectedDoc.permission === 'read' && ( + + + Nur Lesezugriff + + )} + + {holdsLock && ( + + + Schreibzugriff aktiv + + )} + + {isLockedByOther && ( + + + Wird bearbeitet ({activeLock?.userName || 'anderer Nutzer'}) )} @@ -2064,10 +2139,8 @@ export const WorkspaceView: React.FC = ({ type="text" readOnly={isReadOnly} value={selectedDoc.title} - onFocus={() => { - if (!isReadOnly) ensureLock(); - }} - onBlur={() => resetIdleTimer()} + onFocus={handleBlockFocus} + onBlur={handleBlockBlur} onChange={(e) => { if (!isReadOnly) { ensureLock(); @@ -2218,10 +2291,8 @@ export const WorkspaceView: React.FC = ({ type="text" readOnly={isReadOnly} value={block.content} - onFocus={() => { - if (!isReadOnly) ensureLock(); - }} - onBlur={() => resetIdleTimer()} + onFocus={handleBlockFocus} + onBlur={handleBlockBlur} onChange={(e) => handleContentChange(block, e.target.value)} onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} className={`w-full bg-transparent text-sm focus:outline-none ${ @@ -2281,10 +2352,8 @@ export const WorkspaceView: React.FC = ({ type="text" readOnly={isReadOnly} value={block.content} - onFocus={() => { - if (!isReadOnly) ensureLock(); - }} - onBlur={() => resetIdleTimer()} + onFocus={handleBlockFocus} + onBlur={handleBlockBlur} onChange={(e) => handleContentChange(block, e.target.value)} onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} className={`w-full bg-transparent text-lg font-bold text-main focus:outline-none ${ @@ -2300,10 +2369,8 @@ export const WorkspaceView: React.FC = ({ type="text" readOnly={isReadOnly} value={block.content} - onFocus={() => { - if (!isReadOnly) ensureLock(); - }} - onBlur={() => resetIdleTimer()} + onFocus={handleBlockFocus} + onBlur={handleBlockBlur} onChange={(e) => handleContentChange(block, e.target.value)} onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} className={`w-full bg-transparent text-base font-semibold text-accent focus:outline-none ${ @@ -2319,10 +2386,8 @@ export const WorkspaceView: React.FC = ({ type="text" readOnly={isReadOnly} value={block.content} - onFocus={() => { - if (!isReadOnly) ensureLock(); - }} - onBlur={() => resetIdleTimer()} + onFocus={handleBlockFocus} + onBlur={handleBlockBlur} onChange={(e) => handleContentChange(block, e.target.value)} onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} className={`w-full bg-transparent text-sm font-semibold text-main focus:outline-none ${ @@ -2340,10 +2405,8 @@ export const WorkspaceView: React.FC = ({ readOnly={isReadOnly} rows={Math.max(1, Math.ceil((block.content || '').length / 70))} value={block.content} - onFocus={() => { - if (!isReadOnly) ensureLock(); - }} - onBlur={() => resetIdleTimer()} + onFocus={handleBlockFocus} + onBlur={handleBlockBlur} onChange={(e) => handleContentChange(block, e.target.value)} onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} className={`w-full bg-transparent text-xs text-main focus:outline-none resize-none ${ @@ -2361,10 +2424,8 @@ export const WorkspaceView: React.FC = ({ readOnly={isReadOnly} rows={Math.max(2, (block.content || '').split('\n').length)} value={block.content} - onFocus={() => { - if (!isReadOnly) ensureLock(); - }} - onBlur={() => resetIdleTimer()} + onFocus={handleBlockFocus} + onBlur={handleBlockBlur} onChange={(e) => handleContentChange(block, e.target.value)} onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} className={`w-full bg-transparent text-xs font-mono text-main focus:outline-none resize-none ${ @@ -2385,10 +2446,8 @@ export const WorkspaceView: React.FC = ({ readOnly={isReadOnly} rows={Math.max(3, (block.content || '').split('\n').length)} value={block.content} - onFocus={() => { - if (!isReadOnly) ensureLock(); - }} - onBlur={() => resetIdleTimer()} + onFocus={handleBlockFocus} + onBlur={handleBlockBlur} onChange={(e) => handleContentChange(block, e.target.value)} onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} className={`w-full bg-transparent font-mono text-xs text-main focus:outline-none resize-none ${ @@ -2444,10 +2503,8 @@ export const WorkspaceView: React.FC = ({ readOnly={isReadOnly} rows={Math.max(1, Math.ceil((block.content || '').length / 80))} value={block.content} - onFocus={() => { - if (!isReadOnly) ensureLock(); - }} - onBlur={() => resetIdleTimer()} + onFocus={handleBlockFocus} + onBlur={handleBlockBlur} onChange={(e) => handleContentChange(block, e.target.value)} onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} className={`w-full bg-transparent text-sm text-main leading-relaxed focus:outline-none resize-none placeholder:text-faint ${ diff --git a/internal/api/server.go b/internal/api/server.go index 58451fa..e3011c5 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -899,7 +899,7 @@ func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) { userName = user.DisplayName } - info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 30*time.Second) + info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 12*time.Second) if !acquired { w.WriteHeader(http.StatusLocked) _ = json.NewEncoder(w).Encode(map[string]interface{}{ @@ -922,14 +922,16 @@ func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) { func (s *Server) handleHeartbeatDocLock(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") - ok := s.wsHub.HeartbeatDocLock(docID, userID, 30*time.Second) + ok := s.wsHub.HeartbeatDocLock(docID, userID, 12*time.Second) writeJSON(w, http.StatusOK, map[string]bool{"extended": ok}) } func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") - released := s.wsHub.ReleaseDocLock(docID, userID) + user, _ := s.store.GetUserByID(userID) + isAdmin := user != nil && user.Role == "admin" + released := s.wsHub.ReleaseDocLock(docID, userID, isAdmin) if released { s.wsHub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID}) } diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 63ce77c..11000f9 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -185,7 +185,7 @@ func (h *Hub) HeartbeatDocLock(docID, userID string, ttl time.Duration) bool { return true } -func (h *Hub) ReleaseDocLock(docID, userID string) bool { +func (h *Hub) ReleaseDocLock(docID, userID string, isAdmin bool) bool { h.mu.Lock() defer h.mu.Unlock() @@ -193,7 +193,7 @@ func (h *Hub) ReleaseDocLock(docID, userID string) bool { if !ok { return false } - if item.userID == userID || userID == "admin" { + if item.userID == userID || isAdmin || userID == "admin" { delete(h.locks, docID) return true } @@ -217,7 +217,7 @@ func (h *Hub) GetDocLock(docID string) *domain.DocLockInfo { } func (h *Hub) cleanupLocksLoop() { - ticker := time.NewTicker(4 * time.Second) + ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() for range ticker.C { @@ -387,7 +387,7 @@ func (c *Client) readPump() { case "HEARTBEAT_DOC": c.hub.HeartbeatDocLock(docID, c.userID, 30*time.Second) case "UNLOCK_DOC": - if c.hub.ReleaseDocLock(docID, c.userID) { + if c.hub.ReleaseDocLock(docID, c.userID, false) { c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID}) } }