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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
This commit is contained in:
1 parent
50b017f387
commit
0e06cc5577
11 files changed
+1373
-239
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user