import * as signalR from "@microsoft/signalr"; import { v4 as uuidv4 } from "uuid"; const API_URL = "http://localhost:5000"; const CURSOR_THROTTLE_MS = 50; // ==================== TYPES ==================== export interface Collaborator { connectionId: string; userName: string; color: string; joinedAt: string; selectedItemId: string | null; cursorX: number | null; cursorY: number | null; currentViewId: string | null; metadata: unknown; isActive: boolean; lastActivityAt: string; } export interface RoomState { roomKey: string; collaborators: Collaborator[]; joinedAt: string; } export interface DataChangeMessage { itemId: string; itemType: string; changeType: string; fieldPath?: string; newValue: unknown; oldValue?: unknown; senderConnectionId?: string; timestamp: string; } export interface ItemCreatedMessage { itemId: string; itemType: string; item: unknown; parentId?: string; index?: number; senderConnectionId?: string; timestamp: string; } export interface ItemDeletedMessage { itemId: string; itemType: string; senderConnectionId?: string; timestamp: string; } export interface BatchOperationMessage { operationType: string; itemType: string; data: unknown; senderConnectionId?: string; timestamp: string; } export interface SelectionChangedMessage { connectionId: string; itemId: string | null; timestamp: string; } export interface CursorMovedMessage { connectionId: string; x: number; y: number; viewId: string | null; timestamp: string; } export interface ViewChangedMessage { connectionId: string; viewId: string; timestamp: string; } export interface UserTypingMessage { connectionId: string; itemId: string | null; isTyping: boolean; timestamp: string; } export interface SyncRequestMessage { requesterId: string; roomKey: string; timestamp: string; } export interface SyncDataMessage { roomKey: string; dataJson: string; senderConnectionId: string; timestamp: string; } export interface DataSavedMessage { savedBy: string; roomKey: string; timestamp: string; } export interface UserLeftMessage { connectionId: string; userName: string; timestamp: string; } export interface ChangeHistoryEntry { id: string; type: "data_changed" | "item_created" | "item_deleted" | "batch_operation"; description: string; userName: string; userColor: string; timestamp: Date; itemId?: string; itemType?: string; } // ==================== COLORS ==================== export const COLLABORATOR_COLORS = [ "#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F", "#BB8FCE", "#85C1E9", "#F8B500", "#58D68D", ]; export function getColorForUser(userName: string): string { let hash = 0; for (let i = 0; i < userName.length; i++) { hash = userName.charCodeAt(i) + ((hash << 5) - hash); } return COLLABORATOR_COLORS[Math.abs(hash) % COLLABORATOR_COLORS.length]; } export function generateRandomUserName(): string { const adjectives = [ "Veloce", "Creativo", "Brillante", "Agile", "Dinamico", "Preciso", "Elegante", "Audace", ]; const nouns = [ "Designer", "Editor", "Artista", "Creatore", "Architetto", "Compositore", "Maestro", "Inventore", ]; const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; const noun = nouns[Math.floor(Math.random() * nouns.length)]; const num = Math.floor(Math.random() * 100); return `${adj} ${noun} ${num}`; } export function getOrCreateUserName(): string { const stored = localStorage.getItem("apollinare_username"); if (stored) return stored; const newName = generateRandomUserName(); localStorage.setItem("apollinare_username", newName); return newName; } export function saveUserName(userName: string): void { localStorage.setItem("apollinare_username", userName); } // ==================== SERVICE ==================== type EventCallback = (data: T) => void; class CollaborationService { private connection: signalR.HubConnection | null = null; private currentRoom: string | null = null; private userName: string = ""; private userColor: string = ""; private isConnecting = false; private connectAbortController: AbortController | null = null; // Event listeners private userJoinedListeners = new Set>(); private userLeftListeners = new Set>(); private roomStateListeners = new Set>(); private dataChangedListeners = new Set>(); private itemCreatedListeners = new Set>(); private itemDeletedListeners = new Set>(); private batchOperationListeners = new Set< EventCallback >(); private selectionChangedListeners = new Set< EventCallback >(); private cursorMovedListeners = new Set>(); private viewChangedListeners = new Set>(); private userTypingListeners = new Set>(); private syncRequestedListeners = new Set>(); private syncReceivedListeners = new Set>(); private dataSavedListeners = new Set>(); private connectionStateListeners = new Set< EventCallback<{ connected: boolean; connecting: boolean }> >(); // Change history (LIFO) private changeHistory: ChangeHistoryEntry[] = []; private changeHistoryListeners = new Set< EventCallback >(); private maxHistoryEntries = 50; // Collaborators cache for name/color lookup private collaboratorsCache = new Map(); // Cursor throttling private lastCursorSend = 0; private pendingCursorUpdate: { x: number; y: number; viewId: string | null; } | null = null; private cursorThrottleTimer: ReturnType | null = null; // ==================== GETTERS ==================== get isConnected(): boolean { return this.connection?.state === signalR.HubConnectionState.Connected; } get currentUserName(): string { return this.userName; } get currentUserColor(): string { return this.userColor; } get connectionId(): string | null { return this.connection?.connectionId ?? null; } get currentRoomKey(): string | null { return this.currentRoom; } // ==================== CONNECTION ==================== async connect(): Promise { // If already connected, return immediately if (this.isConnected) return; // If already connecting, wait for the connection to complete if (this.isConnecting) { // Wait for connection state to change return new Promise((resolve) => { const checkConnection = () => { if (this.isConnected) { resolve(); } else if (!this.isConnecting) { // Connection attempt finished but not connected - could be cancelled or failed // Don't reject, just resolve silently (caller should check isConnected) resolve(); } else { setTimeout(checkConnection, 100); } }; setTimeout(checkConnection, 100); }); } this.isConnecting = true; this.connectAbortController = new AbortController(); this.notifyConnectionState(); try { this.connection = new signalR.HubConnectionBuilder() .withUrl(`${API_URL}/hubs/collaboration`) .withAutomaticReconnect([0, 1000, 2000, 5000, 10000, 30000]) .configureLogging(signalR.LogLevel.Warning) .build(); this.setupEventHandlers(); // Check if aborted before starting if (this.connectAbortController.signal.aborted) { console.log("[Collaboration] Connection aborted before start"); return; } await this.connection.start(); // Check if aborted after starting (disconnect was called during connection) if (this.connectAbortController.signal.aborted) { console.log( "[Collaboration] Connection aborted after start, disconnecting", ); await this.connection.stop(); return; } console.log("[Collaboration] Connected"); } catch (error) { // Don't log error if it was an intentional abort if (this.connectAbortController?.signal.aborted) { console.log("[Collaboration] Connection aborted"); return; } console.error("[Collaboration] Connection failed:", error); throw error; } finally { this.isConnecting = false; this.connectAbortController = null; this.notifyConnectionState(); } } async disconnect(): Promise { // Abort any pending connection attempt if (this.connectAbortController) { this.connectAbortController.abort(); } if (this.currentRoom) { await this.leaveRoom(); } if (this.connection) { try { await this.connection.stop(); } catch { // Ignore errors when stopping } this.connection = null; } this.notifyConnectionState(); } // ==================== ROOM MANAGEMENT ==================== async joinRoom( roomKey: string, userName?: string, metadata?: unknown, ): Promise { // Ensure we're connected first if (!this.isConnected) { await this.connect(); } // Check connection - if not connected (possibly due to abort), just return silently if (!this.isConnected || !this.connection) { console.log( "[Collaboration] Cannot join room: not connected (connection may have been cancelled)", ); return; } // Leave current room if different if (this.currentRoom && this.currentRoom !== roomKey) { await this.leaveRoom(); } if (this.currentRoom === roomKey) return; this.userName = userName || getOrCreateUserName(); this.userColor = getColorForUser(this.userName); try { await this.connection.invoke( "JoinRoom", roomKey, this.userName, this.userColor, metadata, ); this.currentRoom = roomKey; console.log(`[Collaboration] Joined room: ${roomKey}`); } catch (error) { console.error("[Collaboration] Failed to join room:", error); throw error; } } async leaveRoom(): Promise { if (!this.currentRoom) return; // Only try to invoke if actually connected if (this.isConnected && this.connection) { try { await this.connection.invoke("LeaveRoom", this.currentRoom); console.log(`[Collaboration] Left room: ${this.currentRoom}`); } catch (error) { console.error("[Collaboration] Error leaving room:", error); } } // Always clean up local state this.currentRoom = null; this.collaboratorsCache.clear(); this.changeHistory = []; this.notifyChangeHistory(); } async switchRoom(newRoomKey: string, metadata?: unknown): Promise { if (!this.isConnected) { await this.connect(); } // Check connection - if not connected (possibly due to abort), just return silently if (!this.isConnected || !this.connection) { console.log( "[Collaboration] Cannot switch room: not connected (connection may have been cancelled)", ); return; } try { await this.connection.invoke( "SwitchRoom", newRoomKey, this.userName, this.userColor, metadata, ); this.currentRoom = newRoomKey; this.collaboratorsCache.clear(); console.log(`[Collaboration] Switched to room: ${newRoomKey}`); } catch (error) { console.error("[Collaboration] Failed to switch room:", error); throw error; } } // ==================== DATA SYNC ==================== sendDataChanged( itemId: string, itemType: string, changeType: string, newValue: unknown, fieldPath?: string, ): void { if (!this.canSend()) return; this.connection!.invoke("DataChanged", this.currentRoom, { itemId, itemType, changeType, fieldPath, newValue, }).catch((err) => console.error("[Collaboration] Error sending data change:", err), ); } sendItemCreated( itemId: string, itemType: string, item: unknown, parentId?: string, index?: number, ): void { if (!this.canSend()) return; this.connection!.invoke("ItemCreated", this.currentRoom, { itemId, itemType, item, parentId, index, }).catch((err) => console.error("[Collaboration] Error sending item created:", err), ); } sendItemDeleted(itemId: string, itemType: string): void { if (!this.canSend()) return; this.connection!.invoke("ItemDeleted", this.currentRoom, { itemId, itemType, }).catch((err) => console.error("[Collaboration] Error sending item deleted:", err), ); } sendBatchOperation( operationType: string, itemType: string, data: unknown, ): void { if (!this.canSend()) return; this.connection!.invoke("BatchOperation", this.currentRoom, { operationType, itemType, data, }).catch((err) => console.error("[Collaboration] Error sending batch operation:", err), ); } // ==================== PRESENCE ==================== sendSelectionChanged(itemId: string | null): void { if (!this.canSend()) return; this.connection!.invoke("SelectionChanged", this.currentRoom, itemId).catch( (err) => console.error("[Collaboration] Error sending selection:", err), ); } sendCursorMoved(x: number, y: number, viewId?: string | null): void { if (!this.canSend()) return; const now = Date.now(); if (now - this.lastCursorSend >= CURSOR_THROTTLE_MS) { this.doSendCursor(x, y, viewId ?? null); this.lastCursorSend = now; this.pendingCursorUpdate = null; } else { this.pendingCursorUpdate = { x, y, viewId: viewId ?? null }; if (!this.cursorThrottleTimer) { this.cursorThrottleTimer = setTimeout( () => { if (this.pendingCursorUpdate) { this.doSendCursor( this.pendingCursorUpdate.x, this.pendingCursorUpdate.y, this.pendingCursorUpdate.viewId, ); this.lastCursorSend = Date.now(); this.pendingCursorUpdate = null; } this.cursorThrottleTimer = null; }, CURSOR_THROTTLE_MS - (now - this.lastCursorSend), ); } } } private doSendCursor(x: number, y: number, viewId: string | null): void { this.connection!.invoke( "CursorMoved", this.currentRoom, x, y, viewId, ).catch((err) => console.error("[Collaboration] Error sending cursor:", err), ); } sendViewChanged(viewId: string): void { if (!this.canSend()) return; this.connection!.invoke("ViewChanged", this.currentRoom, viewId).catch( (err) => console.error("[Collaboration] Error sending view change:", err), ); } sendUserTyping(itemId: string | null, isTyping: boolean): void { if (!this.canSend()) return; this.connection!.invoke( "UserTyping", this.currentRoom, itemId, isTyping, ).catch((err) => console.error("[Collaboration] Error sending typing:", err), ); } // ==================== SYNC ==================== requestSync(): void { if (!this.canSend()) return; this.connection!.invoke("RequestSync", this.currentRoom).catch((err) => console.error("[Collaboration] Error requesting sync:", err), ); } sendSync(targetConnectionId: string, dataJson: string): void { if (!this.canSend()) return; this.connection!.invoke( "SendSync", this.currentRoom, targetConnectionId, dataJson, ).catch((err) => console.error("[Collaboration] Error sending sync:", err)); } sendDataSaved(): void { console.log( "[Collaboration] sendDataSaved called, canSend:", this.canSend(), "isConnected:", this.isConnected, "currentRoom:", this.currentRoom, ); if (!this.canSend()) { console.log( "[Collaboration] sendDataSaved: canSend is false, not sending", ); return; } console.log("[Collaboration] sendDataSaved: invoking DataSaved on server"); this.connection!.invoke("DataSaved", this.currentRoom, this.userName).catch( (err) => console.error("[Collaboration] Error sending data saved:", err), ); } // ==================== SUBSCRIPTIONS ==================== onUserJoined(callback: EventCallback): () => void { this.userJoinedListeners.add(callback); return () => this.userJoinedListeners.delete(callback); } onUserLeft(callback: EventCallback): () => void { this.userLeftListeners.add(callback); return () => this.userLeftListeners.delete(callback); } onRoomState(callback: EventCallback): () => void { this.roomStateListeners.add(callback); return () => this.roomStateListeners.delete(callback); } onDataChanged(callback: EventCallback): () => void { this.dataChangedListeners.add(callback); return () => this.dataChangedListeners.delete(callback); } onItemCreated(callback: EventCallback): () => void { this.itemCreatedListeners.add(callback); return () => this.itemCreatedListeners.delete(callback); } onItemDeleted(callback: EventCallback): () => void { this.itemDeletedListeners.add(callback); return () => this.itemDeletedListeners.delete(callback); } onBatchOperation(callback: EventCallback): () => void { this.batchOperationListeners.add(callback); return () => this.batchOperationListeners.delete(callback); } onSelectionChanged( callback: EventCallback, ): () => void { this.selectionChangedListeners.add(callback); return () => this.selectionChangedListeners.delete(callback); } onCursorMoved(callback: EventCallback): () => void { this.cursorMovedListeners.add(callback); return () => this.cursorMovedListeners.delete(callback); } onViewChanged(callback: EventCallback): () => void { this.viewChangedListeners.add(callback); return () => this.viewChangedListeners.delete(callback); } onUserTyping(callback: EventCallback): () => void { this.userTypingListeners.add(callback); return () => this.userTypingListeners.delete(callback); } onSyncRequested(callback: EventCallback): () => void { this.syncRequestedListeners.add(callback); return () => this.syncRequestedListeners.delete(callback); } onSyncReceived(callback: EventCallback): () => void { this.syncReceivedListeners.add(callback); return () => this.syncReceivedListeners.delete(callback); } onDataSaved(callback: EventCallback): () => void { this.dataSavedListeners.add(callback); return () => this.dataSavedListeners.delete(callback); } onConnectionStateChanged( callback: EventCallback<{ connected: boolean; connecting: boolean }>, ): () => void { this.connectionStateListeners.add(callback); return () => this.connectionStateListeners.delete(callback); } onChangeHistoryUpdated( callback: EventCallback, ): () => void { this.changeHistoryListeners.add(callback); return () => this.changeHistoryListeners.delete(callback); } getChangeHistory(): ChangeHistoryEntry[] { return [...this.changeHistory]; } getCollaborator(connectionId: string): Collaborator | undefined { return this.collaboratorsCache.get(connectionId); } // ==================== PRIVATE ==================== private canSend(): boolean { return this.isConnected && this.currentRoom !== null; } private notifyConnectionState(): void { const state = { connected: this.isConnected, connecting: this.isConnecting, }; this.connectionStateListeners.forEach((cb) => { try { cb(state); } catch (e) { console.error("[Collaboration] Listener error:", e); } }); } private notifyChangeHistory(): void { this.changeHistoryListeners.forEach((cb) => { try { cb([...this.changeHistory]); } catch (e) { console.error("[Collaboration] Listener error:", e); } }); } private addChangeHistoryEntry( entry: Omit, ): void { const fullEntry: ChangeHistoryEntry = { ...entry, id: uuidv4(), timestamp: new Date(), }; this.changeHistory.unshift(fullEntry); if (this.changeHistory.length > this.maxHistoryEntries) { this.changeHistory = this.changeHistory.slice(0, this.maxHistoryEntries); } this.notifyChangeHistory(); } private getCollaboratorName(connectionId?: string): string { if (!connectionId) return "Sconosciuto"; return this.collaboratorsCache.get(connectionId)?.userName || "Sconosciuto"; } private getCollaboratorColor(connectionId?: string): string { if (!connectionId) return "#888888"; return this.collaboratorsCache.get(connectionId)?.color || "#888888"; } private setupEventHandlers(): void { if (!this.connection) return; // Room state (received on join) this.connection.on("RoomState", (state: RoomState) => { state.collaborators.forEach((c) => this.collaboratorsCache.set(c.connectionId, c), ); this.roomStateListeners.forEach((cb) => { try { cb(state); } catch (e) { console.error("[Collaboration] Listener error:", e); } }); }); // User joined this.connection.on("UserJoined", (collaborator: Collaborator) => { this.collaboratorsCache.set(collaborator.connectionId, collaborator); this.userJoinedListeners.forEach((cb) => { try { cb(collaborator); } catch (e) { console.error("[Collaboration] Listener error:", e); } }); }); // User left this.connection.on("UserLeft", (message: UserLeftMessage) => { this.collaboratorsCache.delete(message.connectionId); this.userLeftListeners.forEach((cb) => { try { cb(message); } catch (e) { console.error("[Collaboration] Listener error:", e); } }); }); // Data changed this.connection.on("DataChanged", (message: DataChangeMessage) => { this.dataChangedListeners.forEach((cb) => { try { cb(message); } catch (e) { console.error("[Collaboration] Listener error:", e); } }); this.addChangeHistoryEntry({ type: "data_changed", description: `Modificato ${message.itemType}`, userName: this.getCollaboratorName(message.senderConnectionId), userColor: this.getCollaboratorColor(message.senderConnectionId), itemId: message.itemId, itemType: message.itemType, }); }); // Item created this.connection.on("ItemCreated", (message: ItemCreatedMessage) => { this.itemCreatedListeners.forEach((cb) => { try { cb(message); } catch (e) { console.error("[Collaboration] Listener error:", e); } }); this.addChangeHistoryEntry({ type: "item_created", description: `Creato ${message.itemType}`, userName: this.getCollaboratorName(message.senderConnectionId), userColor: this.getCollaboratorColor(message.senderConnectionId), itemId: message.itemId, itemType: message.itemType, }); }); // Item deleted this.connection.on("ItemDeleted", (message: ItemDeletedMessage) => { this.itemDeletedListeners.forEach((cb) => { try { cb(message); } catch (e) { console.error("[Collaboration] Listener error:", e); } }); this.addChangeHistoryEntry({ type: "item_deleted", description: `Eliminato ${message.itemType}`, userName: this.getCollaboratorName(message.senderConnectionId), userColor: this.getCollaboratorColor(message.senderConnectionId), itemId: message.itemId, itemType: message.itemType, }); }); // Batch operation this.connection.on("BatchOperation", (message: BatchOperationMessage) => { this.batchOperationListeners.forEach((cb) => { try { cb(message); } catch (e) { console.error("[Collaboration] Listener error:", e); } }); this.addChangeHistoryEntry({ type: "batch_operation", description: `${message.operationType} su ${message.itemType}`, userName: this.getCollaboratorName(message.senderConnectionId), userColor: this.getCollaboratorColor(message.senderConnectionId), itemType: message.itemType, }); }); // Selection changed this.connection.on( "SelectionChanged", (message: SelectionChangedMessage) => { const collab = this.collaboratorsCache.get(message.connectionId); if (collab) collab.selectedItemId = message.itemId; this.selectionChangedListeners.forEach((cb) => { try { cb(message); } catch (e) { console.error("[Collaboration] Listener error:", e); } }); }, ); // Cursor moved this.connection.on("CursorMoved", (message: CursorMovedMessage) => { const collab = this.collaboratorsCache.get(message.connectionId); if (collab) { collab.cursorX = message.x; collab.cursorY = message.y; collab.currentViewId = message.viewId; } this.cursorMovedListeners.forEach((cb) => { try { cb(message); } catch (e) { console.error("[Collaboration] Listener error:", e); } }); }); // View changed this.connection.on("ViewChanged", (message: ViewChangedMessage) => { const collab = this.collaboratorsCache.get(message.connectionId); if (collab) collab.currentViewId = message.viewId; this.viewChangedListeners.forEach((cb) => { try { cb(message); } catch (e) { console.error("[Collaboration] Listener error:", e); } }); }); // User typing this.connection.on("UserTyping", (message: UserTypingMessage) => { this.userTypingListeners.forEach((cb) => { try { cb(message); } catch (e) { console.error("[Collaboration] Listener error:", e); } }); }); // Sync requested this.connection.on("SyncRequested", (message: SyncRequestMessage) => { this.syncRequestedListeners.forEach((cb) => { try { cb(message); } catch (e) { console.error("[Collaboration] Listener error:", e); } }); }); // Sync received this.connection.on("SyncReceived", (message: SyncDataMessage) => { this.syncReceivedListeners.forEach((cb) => { try { cb(message); } catch (e) { console.error("[Collaboration] Listener error:", e); } }); }); // Data saved this.connection.on("DataSaved", (message: DataSavedMessage) => { this.dataSavedListeners.forEach((cb) => { try { cb(message); } catch (e) { console.error("[Collaboration] Listener error:", e); } }); }); // Reconnection handling this.connection.onreconnecting(() => { console.log("[Collaboration] Reconnecting..."); this.notifyConnectionState(); }); this.connection.onreconnected(async () => { console.log("[Collaboration] Reconnected"); this.notifyConnectionState(); // Rejoin room if we were in one - with a small delay to ensure connection is stable if ( this.currentRoom && this.connection?.state === signalR.HubConnectionState.Connected ) { try { await this.connection.invoke( "JoinRoom", this.currentRoom, this.userName, this.userColor, null, ); console.log(`[Collaboration] Rejoined room: ${this.currentRoom}`); } catch (err) { console.error("[Collaboration] Error rejoining room:", err); } } }); this.connection.onclose(() => { console.log("[Collaboration] Connection closed"); this.notifyConnectionState(); }); } } // Singleton export export const collaborationService = new CollaborationService();