sveltekit-sync
Version:
Local-first sync engine for SvelteKit
208 lines (207 loc) • 6.8 kB
JavaScript
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();
}
}