feat(workspace): add real-time live cursor and presence tracking
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s

- Support ephemeral cursor_moved and client_left events in docroom Hub
- Keep active in-memory cursor presence map per document room
- Synchronize current presence state to newly connected WebSocket clients
- Allow read-only viewers to share cursor positions without document mutation rights
- Throttle frontend cursor move emissions to 100ms with instant blur cleanup
- Render active collaborator presence avatar pills in document header
- Display user-colored block indicator and non-destructive caret overlay with name tag
This commit is contained in:
sandrodach committed 2026-10-26 21:57:19 +01:00
1 parent 85e4010435
commit 95977ba15a
7 files changed
+643 -68

No files matched your search

+203 -5
View File
@@ -1,9 +1,42 @@
import { useEffect, useRef, useState, useCallback } from 'react';
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 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'
@@ -12,10 +45,17 @@ export interface DocSyncEvent {
| 'block_deleted'
| 'block_moved'
| 'title_updated'
| 'blocks_replaced';
| '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;
@@ -32,9 +72,11 @@ export interface DocSyncEvent {
export function useDocumentSync(
docId: string | null,
onRemoteEvent: (evt: DocSyncEvent) => void,
canEdit: boolean = true
canEdit: boolean = true,
currentUser?: { id: string; email?: string; displayName?: string } | null
) {
const [status, setStatus] = useState<DocSyncStatus>('connecting');
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)}`
@@ -45,9 +87,45 @@ export function useDocumentSync(
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 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) {
setStatus('offline');
setRemoteCursors({});
return;
}
@@ -68,7 +146,11 @@ export function useDocumentSync(
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)}`;
)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(
currentClientId
)}&userName=${encodeURIComponent(localUserName)}&userColor=${encodeURIComponent(
localUserColor
)}`;
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting');
@@ -96,10 +178,55 @@ export function useDocumentSync(
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
@@ -132,6 +259,10 @@ export function useDocumentSync(
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;
@@ -145,7 +276,72 @@ export function useDocumentSync(
wsRef.current = null;
}
};
}, [docId]);
}, [docId, localUserName, localUserColor]);
const executeSendCursor = useCallback(
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
const ws = wsRef.current;
if (!docId || !ws || ws.readyState !== WebSocket.OPEN) return;
const payload: DocSyncEvent = {
type: 'cursor_moved',
docId,
clientId: clientIdRef.current,
userId: currentUser?.id,
userName: localUserName,
userColor: localUserColor,
blockId: blockId || undefined,
selectionStart,
selectionEnd,
};
ws.send(JSON.stringify(payload));
lastCursorSendRef.current = Date.now();
},
[docId, currentUser?.id, localUserName, localUserColor]
);
const sendCursorMoved = useCallback(
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
if (!docId) 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);
}
}
},
[docId, executeSendCursor]
);
const sendRawEvent = useCallback(
(evt: DocSyncEvent, fallbackFn?: () => void) => {
@@ -294,6 +490,8 @@ export function useDocumentSync(
return {
status,
clientId: clientIdRef.current,
remoteCursors,
sendCursorMoved,
sendBlockCreated,
sendBlockUpdated,
sendBlockDeleted,