UNPKG

sveltekit-sync

Version:
208 lines (207 loc) 6.8 kB
export class PresenceStore { realtimeClient; tableName; myState = $state({}); othersState = $state(new Map()); followingUserId = $state(null); heartbeatInterval = null; idleTimer = null; eventListeners = new Map(); cursorDebounceTimer = null; cursorDebounceMs = 50; // 50ms debounce for cursor updates constructor(realtimeClient, tableName, user, customState) { this.realtimeClient = realtimeClient; this.tableName = tableName; this.myState = { user: { ...user, color: user.color || this.generateColor() }, status: 'online', lastSeen: Date.now(), custom: customState }; this.setupPresenceSync(); this.startHeartbeat(); this.setupIdleDetection(); } generateColor() { const colors = [ '#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2' ]; return colors[Math.floor(Math.random() * colors.length)]; } setupPresenceSync() { if (!this.realtimeClient) return; this.realtimeClient.on('presence:update', (data) => { const { userId, state } = data; if (userId !== this.myState.user.id) { this.othersState.set(userId, state); this.emit('update', state); } }); this.realtimeClient.on('presence:join', (data) => { const { userId, state } = data; if (userId !== this.myState.user.id) { this.othersState.set(userId, state); this.emit('join', state); } }); this.realtimeClient.on('presence:leave', (data) => { const { userId } = data; const state = this.othersState.get(userId); if (state) { this.othersState.delete(userId); this.emit('leave', state); } }); this.broadcastPresence(); } startHeartbeat() { this.heartbeatInterval = window.setInterval(() => { this.broadcastPresence(); }, 30000); } setupIdleDetection() { window.addEventListener('mousemove', this.resetIdle); window.addEventListener('keydown', this.resetIdle); window.addEventListener('click', this.resetIdle); this.resetIdleTimer(); } resetIdle() { if (this.myState.status === 'idle') { this.setActive(); } this.resetIdleTimer(); } resetIdleTimer() { if (this.idleTimer) clearTimeout(this.idleTimer); this.idleTimer = window.setTimeout(() => { this.setIdle(); }, 5 * 60 * 1000); } broadcastPresence() { if (!this.realtimeClient) return; this.myState.lastSeen = Date.now(); // Use send() instead of emit() for client-to-server communication this.realtimeClient.send('presence:update', { channel: this.tableName, state: this.myState })?.catch((error) => { console.error('Failed to broadcast presence:', error); }); } // Event system (using realtimeClient as base) on(event, handler) { if (!this.eventListeners.has(event)) { this.eventListeners.set(event, new Set()); } this.eventListeners.get(event).add(handler); return () => { const handlers = this.eventListeners.get(event); if (handlers) { handlers.delete(handler); } }; } emit(event, data) { const handlers = this.eventListeners.get(event); if (handlers) { handlers.forEach(handler => handler(data)); } } // Public API updatePresence(state) { this.myState = { ...this.myState, ...state }; this.broadcastPresence(); } updateCursor(position) { this.myState.cursor = position; // Debounce cursor updates to avoid flooding the server if (this.cursorDebounceTimer) { clearTimeout(this.cursorDebounceTimer); } this.cursorDebounceTimer = window.setTimeout(() => { this.broadcastPresence(); }, this.cursorDebounceMs); } updateSelection(selection) { this.myState.selection = selection || undefined; this.broadcastPresence(); } updateEditing(editing) { this.myState.editing = editing || undefined; this.broadcastPresence(); } get myPresence() { return this.myState; } get others() { return Array.from(this.othersState.values()); } get othersCount() { return this.othersState.size; } get onlineCount() { return this.others.reduce((acc, u) => u.status === 'online' ? acc + 1 : acc, this.myState.status === 'online' ? 1 : 0); } getUser(userId) { return this.othersState.get(userId) || null; } getOnlineUsers() { return this.others.filter(u => u.status === 'online'); } getUsersEditing(resourceId) { return this.others.filter(u => u.editing?.resourceId === resourceId); } follow(userId) { this.followingUserId = userId; const unsubscribe = this.on('update', (user) => { if (user.user.id === userId) { this.emit('following:update', user); } }); return () => { this.stopFollowing(); unsubscribe(); }; } stopFollowing() { this.followingUserId = null; } isFollowing(userId) { return this.followingUserId === userId; } setStatus(status) { this.myState.status = status; this.broadcastPresence(); } setIdle() { this.setStatus('idle'); } setActive() { this.setStatus('online'); } destroy() { if (this.heartbeatInterval) clearInterval(this.heartbeatInterval); if (this.idleTimer) clearTimeout(this.idleTimer); if (this.cursorDebounceTimer) clearTimeout(this.cursorDebounceTimer); window.removeEventListener('mousemove', this.resetIdle); window.removeEventListener('keydown', this.resetIdle); window.removeEventListener('click', this.resetIdle); if (this.realtimeClient) { // Use send() instead of emit() for client-to-server communication this.realtimeClient.send('presence:leave', { channel: this.tableName, userId: this.myState.user.id }).catch((error) => { console.error('Failed to send presence:leave:', error); }); } this.eventListeners.clear(); } }