- 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>
556 lines
15 KiB
TypeScript
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
|
|
];
|