feat(collab): redesign note collaboration with REST auto-save, opt-in live session, and block locking
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s

This commit is contained in:
sandrodach committed 2026-10-27 14:02:38 +01:00
1 parent 50b017f387
commit 0e06cc5577
11 files changed
+1373 -239

No files matched your search

+36
View File
@@ -6,6 +6,9 @@ import {
DocumentBlock,
DocShare,
DocLockInfo,
BlockLockInfo,
LiveSessionInfo,
DocLocksResponse,
KanbanColumn,
ListItem,
ListShare,
@@ -625,6 +628,39 @@ 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> {
return request<BlockLockInfo>(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/lock`, {
method: 'POST',
body: JSON.stringify({ userColor, ttlSeconds }),
});
},
async unlockBlock(docId: string, blockId: string): Promise<{ status: string }> {
return request<{ status: string }>(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/unlock`, {
method: 'POST',
});
},
async startLiveSession(docId: string): Promise<LiveSessionInfo> {
return request<LiveSessionInfo>(`/api/v1/documents/${docId}/live/start`, {
method: 'POST',
});
},
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',
+22
View File
@@ -207,6 +207,28 @@ export interface DocLockInfo {
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;
+77 -129
View File
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { DocumentBlock } from './types';
import { getToken, api } from './api';
export type DocSyncStatus = 'connecting' | 'connected' | 'reconnecting' | 'offline';
export type DocSyncStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'offline' | 'blocked';
export interface RemoteCursor {
clientId: string;
@@ -73,9 +73,10 @@ export function useDocumentSync(
docId: string | null,
onRemoteEvent: (evt: DocSyncEvent) => void,
canEdit: boolean = true,
currentUser?: { id: string; email?: string; displayName?: string } | null
currentUser?: { id: string; email?: string; displayName?: string } | null,
enabled: boolean = false
) {
const [status, setStatus] = useState<DocSyncStatus>('connecting');
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>(
@@ -136,10 +137,16 @@ export function useDocumentSync(
}, []);
useEffect(() => {
if (!docId) {
setStatus('offline');
if (!docId || !enabled) {
setStatus('idle');
setRemoteCursors({});
pendingMessagesQueueRef.current = [];
if (wsRef.current) {
try {
wsRef.current.close();
} catch {}
wsRef.current = null;
}
return;
}
@@ -173,13 +180,24 @@ export function useDocumentSync(
try {
ws = new WebSocket(wsUrl);
} catch {
setStatus('offline');
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;
@@ -266,12 +284,17 @@ export function useDocumentSync(
};
ws.onclose = () => {
window.clearTimeout(handshakeTimer);
if (isDisposed) return;
wsRef.current = null;
retryCount += 1;
setStatus(retryCount > 2 ? 'offline' : 'reconnecting');
const delay = Math.min(1000 * Math.pow(1.6, retryCount), 8000);
reconnectTimerRef.current = window.setTimeout(connect, delay);
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);
}
};
};
@@ -304,7 +327,7 @@ export function useDocumentSync(
wsRef.current = null;
}
};
}, [docId, localUserName, localUserColor]);
}, [docId, enabled, localUserName, localUserColor]);
const executeSendCursor = useCallback(
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
@@ -377,165 +400,90 @@ export function useDocumentSync(
);
const sendRawEvent = useCallback(
(evt: DocSyncEvent, fallbackFn?: () => void) => {
(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,
};
const jsonStr = JSON.stringify(payload);
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(jsonStr);
} catch (err) {
console.warn('[DocSync] Failed to send WebSocket message, buffering:', err);
pendingMessagesQueueRef.current.push(jsonStr);
}
} else {
console.warn(
`[DocSync] WebSocket not OPEN (state=${ws ? ws.readyState : 'null'}). Buffering event:`,
evt.type
);
pendingMessagesQueueRef.current.push(jsonStr);
if (fallbackFn) {
if (fallbackTimerRef.current !== null) {
window.clearTimeout(fallbackTimerRef.current);
}
fallbackTimerRef.current = window.setTimeout(() => {
fallbackFn();
}, 600);
}
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,
},
() => {
if (docId && allBlocks) {
api
.patchDocumentBlocks(docId, {
upserted: [block],
blockOrder: allBlocks.map((b) => b.id),
})
.catch(() => {});
}
}
);
(block: DocumentBlock, afterBlockId?: string, _allBlocks?: DocumentBlock[]) => {
sendRawEvent({
type: 'block_created',
block,
afterBlockId,
});
},
[docId, sendRawEvent]
[sendRawEvent]
);
const sendBlockUpdated = useCallback(
(block: DocumentBlock) => {
sendRawEvent(
{
type: 'block_updated',
blockId: block.id,
block,
},
() => {
if (docId) {
api.patchDocumentBlocks(docId, { upserted: [block] }).catch(() => {});
}
}
);
sendRawEvent({
type: 'block_updated',
blockId: block.id,
block,
});
},
[docId, sendRawEvent]
[sendRawEvent]
);
const sendBlockDeleted = useCallback(
(blockId: string, allBlocks?: DocumentBlock[]) => {
sendRawEvent(
{
type: 'block_deleted',
blockId,
},
() => {
if (docId) {
api
.patchDocumentBlocks(docId, {
deletedIds: [blockId],
blockOrder: allBlocks ? allBlocks.map((b) => b.id) : undefined,
})
.catch(() => {});
}
}
);
(blockId: string, _allBlocks?: DocumentBlock[]) => {
sendRawEvent({
type: 'block_deleted',
blockId,
});
},
[docId, sendRawEvent]
[sendRawEvent]
);
const sendBlockMoved = useCallback(
(blockId: string, newIndex: number, level?: number, allBlocks?: DocumentBlock[]) => {
sendRawEvent(
{
type: 'block_moved',
blockId,
newIndex,
level,
},
() => {
if (docId && allBlocks) {
api
.patchDocumentBlocks(docId, {
upserted: allBlocks,
blockOrder: allBlocks.map((b) => b.id),
})
.catch(() => {});
}
}
);
(blockId: string, newIndex: number, level?: number, _allBlocks?: DocumentBlock[]) => {
sendRawEvent({
type: 'block_moved',
blockId,
newIndex,
level,
});
},
[docId, sendRawEvent]
[sendRawEvent]
);
const sendTitleUpdated = useCallback(
(title: string) => {
sendRawEvent(
{
type: 'title_updated',
title,
},
() => {
if (docId) {
api.updateDocument(docId, { title }).catch(() => {});
}
}
);
sendRawEvent({
type: 'title_updated',
title,
});
},
[docId, sendRawEvent]
[sendRawEvent]
);
const sendBlocksReplaced = useCallback(
(blocks: DocumentBlock[]) => {
sendRawEvent(
{
type: 'blocks_replaced',
blocks,
},
() => {
if (docId) {
api.updateDocument(docId, { blocks }).catch(() => {});
}
}
);
sendRawEvent({
type: 'blocks_replaced',
blocks,
});
},
[docId, sendRawEvent]
[sendRawEvent]
);
return {