Files
zentral/frontend/src/services/collaboration.ts
2025-11-28 18:03:34 +01:00

1048 lines
28 KiB
TypeScript

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<T> = (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<EventCallback<Collaborator>>();
private userLeftListeners = new Set<EventCallback<UserLeftMessage>>();
private roomStateListeners = new Set<EventCallback<RoomState>>();
private dataChangedListeners = new Set<EventCallback<DataChangeMessage>>();
private itemCreatedListeners = new Set<EventCallback<ItemCreatedMessage>>();
private itemDeletedListeners = new Set<EventCallback<ItemDeletedMessage>>();
private batchOperationListeners = new Set<
EventCallback<BatchOperationMessage>
>();
private selectionChangedListeners = new Set<
EventCallback<SelectionChangedMessage>
>();
private cursorMovedListeners = new Set<EventCallback<CursorMovedMessage>>();
private viewChangedListeners = new Set<EventCallback<ViewChangedMessage>>();
private userTypingListeners = new Set<EventCallback<UserTypingMessage>>();
private syncRequestedListeners = new Set<EventCallback<SyncRequestMessage>>();
private syncReceivedListeners = new Set<EventCallback<SyncDataMessage>>();
private dataSavedListeners = new Set<EventCallback<DataSavedMessage>>();
private connectionStateListeners = new Set<
EventCallback<{ connected: boolean; connecting: boolean }>
>();
// Change history (LIFO)
private changeHistory: ChangeHistoryEntry[] = [];
private changeHistoryListeners = new Set<
EventCallback<ChangeHistoryEntry[]>
>();
private maxHistoryEntries = 50;
// Collaborators cache for name/color lookup
private collaboratorsCache = new Map<string, Collaborator>();
// Cursor throttling
private lastCursorSend = 0;
private pendingCursorUpdate: {
x: number;
y: number;
viewId: string | null;
} | null = null;
private cursorThrottleTimer: ReturnType<typeof setTimeout> | 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<void> {
// 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<void> {
// 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<void> {
// 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<void> {
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<void> {
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<Collaborator>): () => void {
this.userJoinedListeners.add(callback);
return () => this.userJoinedListeners.delete(callback);
}
onUserLeft(callback: EventCallback<UserLeftMessage>): () => void {
this.userLeftListeners.add(callback);
return () => this.userLeftListeners.delete(callback);
}
onRoomState(callback: EventCallback<RoomState>): () => void {
this.roomStateListeners.add(callback);
return () => this.roomStateListeners.delete(callback);
}
onDataChanged(callback: EventCallback<DataChangeMessage>): () => void {
this.dataChangedListeners.add(callback);
return () => this.dataChangedListeners.delete(callback);
}
onItemCreated(callback: EventCallback<ItemCreatedMessage>): () => void {
this.itemCreatedListeners.add(callback);
return () => this.itemCreatedListeners.delete(callback);
}
onItemDeleted(callback: EventCallback<ItemDeletedMessage>): () => void {
this.itemDeletedListeners.add(callback);
return () => this.itemDeletedListeners.delete(callback);
}
onBatchOperation(callback: EventCallback<BatchOperationMessage>): () => void {
this.batchOperationListeners.add(callback);
return () => this.batchOperationListeners.delete(callback);
}
onSelectionChanged(
callback: EventCallback<SelectionChangedMessage>,
): () => void {
this.selectionChangedListeners.add(callback);
return () => this.selectionChangedListeners.delete(callback);
}
onCursorMoved(callback: EventCallback<CursorMovedMessage>): () => void {
this.cursorMovedListeners.add(callback);
return () => this.cursorMovedListeners.delete(callback);
}
onViewChanged(callback: EventCallback<ViewChangedMessage>): () => void {
this.viewChangedListeners.add(callback);
return () => this.viewChangedListeners.delete(callback);
}
onUserTyping(callback: EventCallback<UserTypingMessage>): () => void {
this.userTypingListeners.add(callback);
return () => this.userTypingListeners.delete(callback);
}
onSyncRequested(callback: EventCallback<SyncRequestMessage>): () => void {
this.syncRequestedListeners.add(callback);
return () => this.syncRequestedListeners.delete(callback);
}
onSyncReceived(callback: EventCallback<SyncDataMessage>): () => void {
this.syncReceivedListeners.add(callback);
return () => this.syncReceivedListeners.delete(callback);
}
onDataSaved(callback: EventCallback<DataSavedMessage>): () => 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<ChangeHistoryEntry[]>,
): () => 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<ChangeHistoryEntry, "id" | "timestamp">,
): 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();