refactor(collab): remove live document collaboration and enforce strictly read-only sharing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
This commit is contained in:
1 parent
aea05e868a
commit
04fa774d10
16 files changed
+179
-3556
No files matched your search
@@ -5,10 +5,6 @@ import {
|
||||
Document,
|
||||
DocumentBlock,
|
||||
DocShare,
|
||||
DocLockInfo,
|
||||
BlockLockInfo,
|
||||
LiveSessionInfo,
|
||||
DocLocksResponse,
|
||||
KanbanColumn,
|
||||
ListItem,
|
||||
ListShare,
|
||||
@@ -641,46 +637,6 @@ export const api = {
|
||||
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async getDocLocks(docId: string): Promise<DocLocksResponse> {
|
||||
const res = await request<DocLocksResponse>(`/api/v1/documents/${docId}/locks`);
|
||||
return {
|
||||
liveSession: res.liveSession || null,
|
||||
blockLocks: ensureArray(res.blockLocks),
|
||||
};
|
||||
},
|
||||
|
||||
async lockBlock(docId: string, blockId: string, userColor?: string, ttlSeconds: number = 30): Promise<BlockLockInfo> {
|
||||
console.log(`[MindSift Lock] Lock anfordern für Block "${blockId}" in Notiz "${docId}"...`);
|
||||
const res = await request<any>(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/lock`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userColor, ttlSeconds, ttl: ttlSeconds }),
|
||||
});
|
||||
console.log(`[MindSift Lock] Lock erfolgreich erhalten für "${blockId}":`, res?.lock || res);
|
||||
return res?.lock || res;
|
||||
},
|
||||
|
||||
async unlockBlock(docId: string, blockId: string): Promise<{ status: string }> {
|
||||
console.log(`[MindSift Lock] Lock freigeben für Block "${blockId}" in Notiz "${docId}"...`);
|
||||
const res = await request<{ status: string }>(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/unlock`, {
|
||||
method: 'POST',
|
||||
});
|
||||
console.log(`[MindSift Lock] Lock freigegeben für "${blockId}"`);
|
||||
return res;
|
||||
},
|
||||
|
||||
async startLiveSession(docId: string): Promise<LiveSessionInfo> {
|
||||
const res = await request<any>(`/api/v1/documents/${docId}/live/start`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return res?.session || res;
|
||||
},
|
||||
|
||||
async endLiveSession(docId: string): Promise<{ status: string }> {
|
||||
return request<{ status: string }>(`/api/v1/documents/${docId}/live/end`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
async moveDocument(id: string, folderPath: string): Promise<Document> {
|
||||
const d = await request<Document>(`/api/v1/documents/${id}/move`, {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -200,35 +200,6 @@ export interface DocShare {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DocLockInfo {
|
||||
docId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface BlockLockInfo {
|
||||
docId: string;
|
||||
blockId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
userColor?: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface LiveSessionInfo {
|
||||
docId: string;
|
||||
initiatorId: string;
|
||||
initiatorName: string;
|
||||
startedAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface DocLocksResponse {
|
||||
liveSession?: LiveSessionInfo | null;
|
||||
blockLocks: BlockLockInfo[];
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
id: string;
|
||||
userId: string;
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { DocumentBlock } from './types';
|
||||
import { getToken, api } from './api';
|
||||
|
||||
export type DocSyncStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'offline' | 'blocked';
|
||||
|
||||
export interface RemoteCursor {
|
||||
clientId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
userColor: string;
|
||||
blockId: string | null;
|
||||
selectionStart?: number;
|
||||
selectionEnd?: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export const CURSOR_COLORS = [
|
||||
'#3b82f6', // blue
|
||||
'#10b981', // emerald
|
||||
'#f59e0b', // amber
|
||||
'#ec4899', // pink
|
||||
'#8b5cf6', // purple
|
||||
'#06b6d4', // cyan
|
||||
'#f97316', // orange
|
||||
'#14b8a6', // teal
|
||||
'#6366f1', // indigo
|
||||
];
|
||||
|
||||
export function getUserColor(idOrName: string): string {
|
||||
if (!idOrName) return CURSOR_COLORS[0];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < idOrName.length; i++) {
|
||||
hash = (hash << 5) - hash + idOrName.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
|
||||
}
|
||||
|
||||
export interface DocSyncEvent {
|
||||
type:
|
||||
| 'doc_state'
|
||||
| 'block_created'
|
||||
| 'block_updated'
|
||||
| 'block_deleted'
|
||||
| 'block_moved'
|
||||
| 'title_updated'
|
||||
| 'blocks_replaced'
|
||||
| 'cursor_moved'
|
||||
| 'client_left';
|
||||
docId?: string;
|
||||
clientId?: string;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
userColor?: string;
|
||||
selectionStart?: number;
|
||||
selectionEnd?: number;
|
||||
activeCursors?: RemoteCursor[];
|
||||
title?: string;
|
||||
block?: DocumentBlock;
|
||||
blockId?: string;
|
||||
afterBlockId?: string;
|
||||
newIndex?: number;
|
||||
content?: string;
|
||||
level?: number;
|
||||
checked?: boolean;
|
||||
collapsed?: boolean;
|
||||
blockType?: string;
|
||||
blocks?: DocumentBlock[];
|
||||
}
|
||||
|
||||
export function useDocumentSync(
|
||||
docId: string | null,
|
||||
onRemoteEvent: (evt: DocSyncEvent) => void,
|
||||
canEdit: boolean = true,
|
||||
currentUser?: { id: string; email?: string; displayName?: string } | null,
|
||||
enabled: boolean = false
|
||||
) {
|
||||
const [status, setStatus] = useState<DocSyncStatus>(enabled ? 'connecting' : 'idle');
|
||||
const [remoteCursors, setRemoteCursors] = useState<Record<string, RemoteCursor>>({});
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const clientIdRef = useRef<string>(
|
||||
`cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
|
||||
);
|
||||
const onRemoteEventRef = useRef(onRemoteEvent);
|
||||
onRemoteEventRef.current = onRemoteEvent;
|
||||
|
||||
const docIdRef = useRef(docId);
|
||||
docIdRef.current = docId;
|
||||
const canEditRef = useRef(canEdit);
|
||||
canEditRef.current = canEdit;
|
||||
const currentUserRef = useRef(currentUser);
|
||||
currentUserRef.current = currentUser;
|
||||
|
||||
const reconnectTimerRef = useRef<number | null>(null);
|
||||
const fallbackTimerRef = useRef<number | null>(null);
|
||||
|
||||
const localUserName =
|
||||
currentUser?.displayName || currentUser?.email?.split('@')[0] || 'Anonym';
|
||||
const localUserColor = useMemo(
|
||||
() => getUserColor(currentUser?.id || clientIdRef.current),
|
||||
[currentUser?.id]
|
||||
);
|
||||
const localUserNameRef = useRef(localUserName);
|
||||
localUserNameRef.current = localUserName;
|
||||
const localUserColorRef = useRef(localUserColor);
|
||||
localUserColorRef.current = localUserColor;
|
||||
|
||||
const pendingMessagesQueueRef = useRef<string[]>([]);
|
||||
|
||||
const lastCursorSendRef = useRef<number>(0);
|
||||
const pendingCursorTimerRef = useRef<number | null>(null);
|
||||
const pendingCursorPayloadRef = useRef<{
|
||||
blockId: string | null;
|
||||
selectionStart?: number;
|
||||
selectionEnd?: number;
|
||||
} | null>(null);
|
||||
|
||||
// Clean up stale remote cursors (> 30s)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
setRemoteCursors((prev) => {
|
||||
let hasStale = false;
|
||||
const next: Record<string, RemoteCursor> = {};
|
||||
for (const [key, cursor] of Object.entries(prev)) {
|
||||
if (now - cursor.updatedAt < 30000) {
|
||||
next[key] = cursor;
|
||||
} else {
|
||||
hasStale = true;
|
||||
}
|
||||
}
|
||||
return hasStale ? next : prev;
|
||||
});
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!docId || !enabled) {
|
||||
setStatus('idle');
|
||||
setRemoteCursors({});
|
||||
pendingMessagesQueueRef.current = [];
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.close();
|
||||
} catch {}
|
||||
wsRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pendingMessagesQueueRef.current = [];
|
||||
let isDisposed = false;
|
||||
let retryCount = 0;
|
||||
const currentClientId = `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
|
||||
clientIdRef.current = currentClientId;
|
||||
|
||||
const connect = () => {
|
||||
if (isDisposed) return;
|
||||
|
||||
if (typeof window !== 'undefined' && sessionStorage.getItem('mindsift_ws_blocked') === 'true') {
|
||||
console.info('[DocSync] WebSockets im Firmennetzwerk blockiert (Cache). Live-Transport übersprungen.');
|
||||
setStatus('blocked');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
setStatus('offline');
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent(
|
||||
docId
|
||||
)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(
|
||||
currentClientId
|
||||
)}&userName=${encodeURIComponent(localUserName)}&userColor=${encodeURIComponent(
|
||||
localUserColor
|
||||
)}`;
|
||||
|
||||
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting');
|
||||
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
} catch {
|
||||
setStatus('blocked');
|
||||
return;
|
||||
}
|
||||
|
||||
wsRef.current = ws;
|
||||
|
||||
const handshakeTimer = window.setTimeout(() => {
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('[DocSync] WebSocket handshake timed out (corporate firewall/proxy block detected).');
|
||||
setStatus('blocked');
|
||||
try {
|
||||
ws.close();
|
||||
} catch {}
|
||||
}
|
||||
}, 4500);
|
||||
|
||||
ws.onopen = () => {
|
||||
window.clearTimeout(handshakeTimer);
|
||||
if (isDisposed) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
retryCount = 0;
|
||||
setStatus('connected');
|
||||
|
||||
// Flush any queued messages buffered while connecting or reconnecting
|
||||
if (pendingMessagesQueueRef.current.length > 0) {
|
||||
const queue = [...pendingMessagesQueueRef.current];
|
||||
pendingMessagesQueueRef.current = [];
|
||||
for (const msg of queue) {
|
||||
try {
|
||||
ws.send(msg);
|
||||
} catch (err) {
|
||||
console.warn('[DocSync] Failed to flush queued message:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (isDisposed) return;
|
||||
try {
|
||||
const parsed: DocSyncEvent = JSON.parse(event.data);
|
||||
if (!parsed || !parsed.type) return;
|
||||
|
||||
// Echo protection: ignore events originating from our own clientId (except initial doc_state)
|
||||
if (parsed.type !== 'doc_state' && parsed.clientId && parsed.clientId === currentClientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === 'doc_state') {
|
||||
if (Array.isArray(parsed.activeCursors)) {
|
||||
const initialCursors: Record<string, RemoteCursor> = {};
|
||||
for (const c of parsed.activeCursors) {
|
||||
if (c.clientId && c.clientId !== currentClientId) {
|
||||
initialCursors[c.clientId] = {
|
||||
...c,
|
||||
updatedAt: c.updatedAt || Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
setRemoteCursors(initialCursors);
|
||||
}
|
||||
} else if (parsed.type === 'cursor_moved') {
|
||||
if (parsed.clientId && parsed.clientId !== currentClientId) {
|
||||
setRemoteCursors((prev) => ({
|
||||
...prev,
|
||||
[parsed.clientId!]: {
|
||||
clientId: parsed.clientId!,
|
||||
userId: parsed.userId || '',
|
||||
userName: parsed.userName || 'Anonym',
|
||||
userColor: parsed.userColor || '#3b82f6',
|
||||
blockId:
|
||||
parsed.blockId !== undefined
|
||||
? parsed.blockId
|
||||
: prev[parsed.clientId!]?.blockId ?? null,
|
||||
selectionStart: parsed.selectionStart,
|
||||
selectionEnd: parsed.selectionEnd,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
} else if (parsed.type === 'client_left') {
|
||||
if (parsed.clientId) {
|
||||
setRemoteCursors((prev) => {
|
||||
if (!prev[parsed.clientId!]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[parsed.clientId!];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onRemoteEventRef.current(parsed);
|
||||
} catch {
|
||||
// Ignore malformed frames
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// Handled in onclose
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
window.clearTimeout(handshakeTimer);
|
||||
if (isDisposed) return;
|
||||
wsRef.current = null;
|
||||
retryCount += 1;
|
||||
if (retryCount >= 2) {
|
||||
setStatus('blocked');
|
||||
} else {
|
||||
setStatus('reconnecting');
|
||||
const delay = Math.min(1000 * Math.pow(1.6, retryCount), 4000);
|
||||
reconnectTimerRef.current = window.setTimeout(connect, delay);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
if (reconnectTimerRef.current !== null) {
|
||||
window.clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
if (fallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(fallbackTimerRef.current);
|
||||
fallbackTimerRef.current = null;
|
||||
}
|
||||
if (pendingCursorTimerRef.current !== null) {
|
||||
window.clearTimeout(pendingCursorTimerRef.current);
|
||||
pendingCursorTimerRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.onopen = null;
|
||||
wsRef.current.onmessage = null;
|
||||
wsRef.current.onerror = null;
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [docId, enabled, localUserName, localUserColor]);
|
||||
|
||||
const executeSendCursor = useCallback(
|
||||
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
|
||||
const ws = wsRef.current;
|
||||
const activeDocId = docIdRef.current;
|
||||
if (!activeDocId || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const payload: DocSyncEvent = {
|
||||
type: 'cursor_moved',
|
||||
docId: activeDocId,
|
||||
clientId: clientIdRef.current,
|
||||
userId: currentUserRef.current?.id,
|
||||
userName: localUserNameRef.current,
|
||||
userColor: localUserColorRef.current,
|
||||
blockId: blockId || undefined,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
};
|
||||
try {
|
||||
ws.send(JSON.stringify(payload));
|
||||
} catch {
|
||||
// Ephemeral cursor, ignore send failure
|
||||
}
|
||||
lastCursorSendRef.current = Date.now();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const sendCursorMoved = useCallback(
|
||||
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
|
||||
if (!docIdRef.current) return;
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastCursorSendRef.current;
|
||||
|
||||
// When leaving focus / blur (blockId === null), send immediately
|
||||
if (blockId === null) {
|
||||
if (pendingCursorTimerRef.current !== null) {
|
||||
window.clearTimeout(pendingCursorTimerRef.current);
|
||||
pendingCursorTimerRef.current = null;
|
||||
}
|
||||
pendingCursorPayloadRef.current = null;
|
||||
executeSendCursor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Max once per 100ms
|
||||
if (elapsed >= 100) {
|
||||
if (pendingCursorTimerRef.current !== null) {
|
||||
window.clearTimeout(pendingCursorTimerRef.current);
|
||||
pendingCursorTimerRef.current = null;
|
||||
}
|
||||
executeSendCursor(blockId, selectionStart, selectionEnd);
|
||||
} else {
|
||||
pendingCursorPayloadRef.current = { blockId, selectionStart, selectionEnd };
|
||||
if (pendingCursorTimerRef.current === null) {
|
||||
pendingCursorTimerRef.current = window.setTimeout(() => {
|
||||
pendingCursorTimerRef.current = null;
|
||||
if (pendingCursorPayloadRef.current) {
|
||||
executeSendCursor(
|
||||
pendingCursorPayloadRef.current.blockId,
|
||||
pendingCursorPayloadRef.current.selectionStart,
|
||||
pendingCursorPayloadRef.current.selectionEnd
|
||||
);
|
||||
pendingCursorPayloadRef.current = null;
|
||||
}
|
||||
}, 100 - elapsed);
|
||||
}
|
||||
}
|
||||
},
|
||||
[executeSendCursor]
|
||||
);
|
||||
|
||||
const sendRawEvent = useCallback(
|
||||
(evt: DocSyncEvent) => {
|
||||
const activeDocId = docIdRef.current;
|
||||
const activeCanEdit = canEditRef.current;
|
||||
if (!activeDocId || !activeCanEdit) return;
|
||||
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const payload: DocSyncEvent = {
|
||||
...evt,
|
||||
docId: activeDocId,
|
||||
clientId: clientIdRef.current,
|
||||
};
|
||||
try {
|
||||
ws.send(JSON.stringify(payload));
|
||||
} catch (err) {
|
||||
console.warn('[DocSync] Failed to send WebSocket message:', err);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const sendBlockCreated = useCallback(
|
||||
(block: DocumentBlock, afterBlockId?: string, _allBlocks?: DocumentBlock[]) => {
|
||||
sendRawEvent({
|
||||
type: 'block_created',
|
||||
block,
|
||||
afterBlockId,
|
||||
});
|
||||
},
|
||||
[sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlockUpdated = useCallback(
|
||||
(block: DocumentBlock) => {
|
||||
sendRawEvent({
|
||||
type: 'block_updated',
|
||||
blockId: block.id,
|
||||
block,
|
||||
});
|
||||
},
|
||||
[sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlockDeleted = useCallback(
|
||||
(blockId: string, _allBlocks?: DocumentBlock[]) => {
|
||||
sendRawEvent({
|
||||
type: 'block_deleted',
|
||||
blockId,
|
||||
});
|
||||
},
|
||||
[sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlockMoved = useCallback(
|
||||
(blockId: string, newIndex: number, level?: number, _allBlocks?: DocumentBlock[]) => {
|
||||
sendRawEvent({
|
||||
type: 'block_moved',
|
||||
blockId,
|
||||
newIndex,
|
||||
level,
|
||||
});
|
||||
},
|
||||
[sendRawEvent]
|
||||
);
|
||||
|
||||
const sendTitleUpdated = useCallback(
|
||||
(title: string) => {
|
||||
sendRawEvent({
|
||||
type: 'title_updated',
|
||||
title,
|
||||
});
|
||||
},
|
||||
[sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlocksReplaced = useCallback(
|
||||
(blocks: DocumentBlock[]) => {
|
||||
sendRawEvent({
|
||||
type: 'blocks_replaced',
|
||||
blocks,
|
||||
});
|
||||
},
|
||||
[sendRawEvent]
|
||||
);
|
||||
|
||||
return {
|
||||
status,
|
||||
clientId: clientIdRef.current,
|
||||
remoteCursors,
|
||||
sendCursorMoved,
|
||||
sendBlockCreated,
|
||||
sendBlockUpdated,
|
||||
sendBlockDeleted,
|
||||
sendBlockMoved,
|
||||
sendTitleUpdated,
|
||||
sendBlocksReplaced,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user