/** * Presence Manager * Tracks connected users, their cursor positions, and idle state * Integrates with WebSocket for real-time presence updates */ import { WebsocketProvider } from 'y-websocket'; import { WebSocketConnection } from './websocket-connection'; /** * User presence state */ export interface UserPresence { userId: string; name: string; color: string; cursorPosition: number | null; selectionStart: number | null; selectionEnd: number | null; editingContext: string | null; // e.g., scene ID or element being edited lastActivity: Date; status: 'active' | 'idle' | 'away'; } /** * Presence update message for WebSocket */ export interface PresenceUpdateMessage { type: 'presence:update'; userId: string; presence: Omit; timestamp: number; } /** * User join event */ export interface UserJoinMessage { type: 'presence:join'; userId: string; presence: Omit; timestamp: number; } /** * User leave event */ export interface UserLeaveMessage { type: 'presence:leave'; userId: string; timestamp: number; } /** * Full presence state from server */ export interface PresenceStateMessage { type: 'presence:state'; users: Record>; timestamp: number; } /** * Presence message type discriminator */ export type PresenceMessage = | PresenceUpdateMessage | UserJoinMessage | UserLeaveMessage | PresenceStateMessage; /** * Options for PresenceManager */ export interface PresenceManagerOptions { userId: string; userName: string; userColor: string; idleTimeoutMs?: number; broadcastIntervalMs?: number; } /** * Callback types for presence events */ export type OnUserJoin = (userId: string, presence: Omit) => void; export type OnUserLeave = (userId: string) => void; export type OnPresenceUpdate = (userId: string, presence: UserPresence) => void; export type OnPresenceState = (users: Record) => void; /** * PresenceManager class * Manages local user presence and tracks remote users */ export class PresenceManager { private userId: string; private userName: string; private userColor: string; private idleTimeoutMs: number; private broadcastIntervalMs: number; private provider: WebsocketProvider | null = null; private connection: WebSocketConnection | null = null; // Remote users' presence state private remoteUsers: Map = new Map(); // Local user's current state private localPresence: UserPresence; // Timers private idleTimer: ReturnType | null = null; private broadcastTimer: ReturnType | null = null; // Event callbacks private onUserJoinCallbacks: Set = new Set(); private onUserLeaveCallbacks: Set = new Set(); private onPresenceUpdateCallbacks: Set = new Set(); private onPresenceStateCallbacks: Set = new Set(); // Activity tracking private lastActivityTime: Date = new Date(); private isInitialized: boolean = false; constructor(options: PresenceManagerOptions) { this.userId = options.userId; this.userName = options.userName; this.userColor = options.userColor; this.idleTimeoutMs = options.idleTimeoutMs || 30000; // 30 seconds default this.broadcastIntervalMs = options.broadcastIntervalMs || 1000; // 1 second default // Initialize local presence this.localPresence = { userId: this.userId, name: this.userName, color: this.userColor, cursorPosition: null, selectionStart: null, selectionEnd: null, editingContext: null, lastActivity: new Date(), status: 'active', }; } /** * Initialize the presence manager with a WebSocket connection */ initialize(connection: WebSocketConnection): void { if (this.isInitialized) { return; } this.connection = connection; this.provider = connection.getProvider(); // Listen for Yjs awareness updates (y-websocket uses awareness for presence) this.provider.on('awareness', this.handleAwarenessUpdate); // Listen for generic message events for custom presence messages this.provider.on('message', this.handlePresenceMessage); // Start idle monitoring this.startIdleMonitor(); // Start periodic presence broadcast this.startPresenceBroadcast(); this.isInitialized = true; console.log(`[PresenceManager] Initialized for user ${this.userName} (${this.userId})`); } /** * Shutdown the presence manager */ shutdown(): void { if (this.provider) { this.provider.off('awareness', this.handleAwarenessUpdate); this.provider.off('message', this.handlePresenceMessage); } if (this.broadcastTimer) { clearInterval(this.broadcastTimer); this.broadcastTimer = null; } if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null; } // Send leave message this.sendLeaveMessage(); this.isInitialized = false; console.log(`[PresenceManager] Shutdown for user ${this.userName}`); } /** * Update cursor position */ updateCursorPosition(position: number, selectionStart?: number, selectionEnd?: number): void { this.localPresence.cursorPosition = position; this.localPresence.selectionStart = selectionStart ?? null; this.localPresence.selectionEnd = selectionEnd ?? null; this.recordActivity(); } /** * Update editing context (e.g., which scene or element is being edited) */ updateEditingContext(context: string | null): void { this.localPresence.editingContext = context; this.recordActivity(); } /** * Get all connected users */ getConnectedUsers(): UserPresence[] { return Array.from(this.remoteUsers.values()); } /** * Get a specific user by ID */ getUser(userId: string): UserPresence | undefined { return this.remoteUsers.get(userId); } /** * Get the local user's presence */ getLocalPresence(): UserPresence { return this.localPresence; } /** * Check if user is currently idle */ isUserIdle(userId: string): boolean { const user = userId === this.userId ? this.localPresence : this.remoteUsers.get(userId); return user?.status === 'idle'; } /** * Record user activity and reset idle timer */ recordActivity(): void { this.lastActivityTime = new Date(); const wasIdle = this.localPresence.status === 'idle'; // Update local status this.localPresence.lastActivity = this.lastActivityTime; this.localPresence.status = 'active'; // If was idle, notify listeners if (wasIdle) { this.onPresenceUpdateCallbacks.forEach(callback => { callback(this.userId, { ...this.localPresence }); }); } } /** * Register callback for user join events */ onUserJoin(callback: OnUserJoin): void { this.onUserJoinCallbacks.add(callback); } /** * Remove user join callback */ offUserJoin(callback: OnUserJoin): void { this.onUserJoinCallbacks.delete(callback); } /** * Register callback for user leave events */ onUserLeave(callback: OnUserLeave): void { this.onUserLeaveCallbacks.add(callback); } /** * Remove user leave callback */ offUserLeave(callback: OnUserLeave): void { this.onUserLeaveCallbacks.delete(callback); } /** * Register callback for presence updates */ onPresenceUpdate(callback: OnPresenceUpdate): void { this.onPresenceUpdateCallbacks.add(callback); } /** * Remove presence update callback */ offPresenceUpdate(callback: OnPresenceUpdate): void { this.onPresenceUpdateCallbacks.delete(callback); } /** * Register callback for full presence state */ onPresenceState(callback: OnPresenceState): void { this.onPresenceStateCallbacks.add(callback); } /** * Remove presence state callback */ offPresenceState(callback: OnPresenceState): void { this.onPresenceStateCallbacks.delete(callback); } /** * Send presence update to server */ private sendPresenceUpdate(): void { if (!this.provider) return; // Update awareness state (y-websocket standard) const awareness = this.provider.awareness; if (awareness) { const currentState = awareness.getLocalState(); awareness.setLocalStateField('presence', { userId: this.localPresence.userId, name: this.localPresence.name, color: this.localPresence.color, cursorPosition: this.localPresence.cursorPosition, selectionStart: this.localPresence.selectionStart, selectionEnd: this.localPresence.selectionEnd, editingContext: this.localPresence.editingContext, status: this.localPresence.status, }); } // Note: Custom messages are sent via awareness state only // y-websocket doesn't expose a direct send method for custom messages } /** * Send user leave message */ private sendLeaveMessage(): void { // User leave is handled automatically by awareness when connection closes // y-websocket doesn't support custom leave messages } /** * Start idle monitoring timer */ private startIdleMonitor(): void { const checkIdle = () => { const now = new Date(); const idleDuration = now.getTime() - this.lastActivityTime.getTime(); if (idleDuration >= this.idleTimeoutMs && this.localPresence.status !== 'idle') { this.localPresence.status = 'idle'; this.onPresenceUpdateCallbacks.forEach(callback => { callback(this.userId, { ...this.localPresence }); }); console.log(`[PresenceManager] User ${this.userName} marked as idle`); } this.idleTimer = setTimeout(checkIdle, 1000); }; this.idleTimer = setTimeout(checkIdle, 1000); } /** * Start periodic presence broadcast */ private startPresenceBroadcast(): void { this.broadcastTimer = setInterval(() => { this.sendPresenceUpdate(); }, this.broadcastIntervalMs); } /** * Process awareness update from y-websocket */ private processAwarenessUpdate(states: Map): void { states.forEach((state, clientId) => { if (clientId.toString() === this.userId) { return; // Skip own state } if (state.presence) { const presence: UserPresence = { ...state.presence, lastActivity: new Date(state.lastActivity || Date.now()), }; const wasKnown = this.remoteUsers.has(presence.userId); this.remoteUsers.set(presence.userId, presence); if (!wasKnown) { this.onUserJoinCallbacks.forEach(callback => { callback(presence.userId, { userId: presence.userId, name: presence.name, color: presence.color, cursorPosition: presence.cursorPosition, selectionStart: presence.selectionStart, selectionEnd: presence.selectionEnd, editingContext: presence.editingContext, status: presence.status, }); }); } else { this.onPresenceUpdateCallbacks.forEach(callback => { callback(presence.userId, presence); }); } } }); // Clean up disconnected users states.forEach((_, clientId) => { if (!this.remoteUsers.has(clientId.toString())) { const removedUserId = clientId.toString(); // Keep users that have sent presence } }); } /** * Process custom presence message */ private processPresenceMessage(message: PresenceMessage): void { switch (message.type) { case 'presence:update': const existingUser = this.remoteUsers.get(message.userId); const updatedPresence: UserPresence = { ...message.presence, lastActivity: new Date(message.timestamp), }; this.remoteUsers.set(message.userId, updatedPresence); if (!existingUser) { // New user this.onUserJoinCallbacks.forEach(callback => { callback(message.userId, message.presence); }); } this.onPresenceUpdateCallbacks.forEach(callback => { callback(message.userId, updatedPresence); }); break; case 'presence:join': const joinPresence: UserPresence = { ...message.presence, lastActivity: new Date(message.timestamp), status: 'active', }; this.remoteUsers.set(message.userId, joinPresence); this.onUserJoinCallbacks.forEach(callback => { callback(message.userId, { userId: message.presence.userId, name: message.presence.name, color: message.presence.color, cursorPosition: message.presence.cursorPosition, selectionStart: message.presence.selectionStart, selectionEnd: message.presence.selectionEnd, editingContext: message.presence.editingContext, status: 'active', }); }); break; case 'presence:leave': this.remoteUsers.delete(message.userId); this.onUserLeaveCallbacks.forEach(callback => { callback(message.userId); }); break; case 'presence:state': // Full state sync from server this.remoteUsers.clear(); Object.entries(message.users).forEach(([userId, presence]) => { const userPresence: UserPresence = { ...presence, lastActivity: new Date(Date.now()), }; this.remoteUsers.set(userId, userPresence); }); this.onPresenceStateCallbacks.forEach(callback => { callback(Object.fromEntries(this.remoteUsers.entries())); }); break; } } // Store handler references for cleanup private handleAwarenessUpdate = (event: { states: Map }) => { this.processAwarenessUpdate(event.states); }; private handlePresenceMessage = (event: { message: PresenceMessage }) => { this.processPresenceMessage(event.message); }; } /** * Generate a random color for a user (for cursor/display) */ export function generateUserColor(userId: string): string { // Use a deterministic hash to generate consistent colors const colors = [ '#ef4444', // red '#f97316', // orange '#eab308', // yellow '#22c55e', // green '#06b6d4', // cyan '#3b82f6', // blue '#8b5cf6', // violet '#ec4899', // pink ]; let hash = 0; for (let i = 0; i < userId.length; i++) { hash = userId.charCodeAt(i) + ((hash << 5) - hash); } return colors[Math.abs(hash) % colors.length]!; } /** * Default user colors for the application */ export const DEFAULT_USER_COLORS = [ '#ef4444', // red '#f97316', // orange '#eab308', // yellow '#22c55e', // green '#06b6d4', // cyan '#3b82f6', // blue '#8b5cf6', // violet '#ec4899', // pink '#6366f1', // indigo '#14b8a6', // teal ];