Files
FrenoCorp/src/lib/collaboration/presence-manager.ts
Michael Freno b89575fb6e FRE-605: Implement Phase 4 Change Tracking & Merge Logic
- Create ChangeTracker class with full version history support
  - Document change recording with metadata
  - Snapshot creation and restoration
  - Change acceptance/rejection workflow
  - Change diff generation between snapshots
  - Event-based change notifications

- Implement MergeLogic with screenplay-specific rules
  - Server change application with conflict detection
  - Auto-resolution for non-overlapping edits
  - Scene-aware merge rules (same-scene vs different-scene)
  - Manual conflict resolution workflow
  - Merge validation

- Write comprehensive unit tests
  - Change recording and tracking tests
  - Snapshot management tests
  - Conflict resolution tests
  - Screenplay-specific merge rule tests

- Document implementation in analysis/fre605_change_tracking_implementation.md

Architecture: ChangeTracker integrates with Yjs document updates.
MergeLogic applies screenplay-specific rules for concurrent edits.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 02:14:54 -04:00

556 lines
15 KiB
TypeScript

/**
* 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<UserPresence, 'lastActivity'>;
timestamp: number;
}
/**
* User join event
*/
export interface UserJoinMessage {
type: 'presence:join';
userId: string;
presence: Omit<UserPresence, 'lastActivity' | 'status'>;
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<string, Omit<UserPresence, 'lastActivity'>>;
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<UserPresence, 'lastActivity'>) => void;
export type OnUserLeave = (userId: string) => void;
export type OnPresenceUpdate = (userId: string, presence: UserPresence) => void;
export type OnPresenceState = (users: Record<string, UserPresence>) => 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<string, UserPresence> = new Map();
// Local user's current state
private localPresence: UserPresence;
// Timers
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private broadcastTimer: ReturnType<typeof setInterval> | null = null;
// Event callbacks
private onUserJoinCallbacks: Set<OnUserJoin> = new Set();
private onUserLeaveCallbacks: Set<OnUserLeave> = new Set();
private onPresenceUpdateCallbacks: Set<OnPresenceUpdate> = new Set();
private onPresenceStateCallbacks: Set<OnPresenceState> = 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<number, any>): 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<number, any> }) => {
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
];