import { create } from 'zustand'; import type { Activity } from '@backspace/shared'; import { ACTIVITY_LIMITS } from '@backspace/shared/src/activities.js'; import { wsSendAll } from '../hooks/useWebSocket'; let pushTimer: ReturnType | null = null; interface ActivityState { userActivities: Map; showActivity: boolean; myActivities: Activity[] | null; setUserActivities: (userId: string, activities: Activity[]) => void; clearUserActivities: (userId: string) => void; initActivities: (activityMap: Record) => void; setShowActivity: (show: boolean) => void; pushActivities: (activities: Activity[]) => void; setSourceActivities: (source: string, activities: Activity[], opts?: { immediate?: boolean }) => void; reset: () => void; } /** * Activities kept per producer. The desktop process detector and Spotify report * independently of each other, and a plain replace would let whichever spoke * last erase the other — losing precisely the case this exists for: a game and * Spotify at the same time. */ const bySource = new Map(); export const useActivityStore = create((set, get) => ({ userActivities: new Map(), showActivity: true, myActivities: null, setUserActivities: (userId, activities) => { set((state) => { const next = new Map(state.userActivities); if (activities.length === 0) { next.delete(userId); } else { next.set(userId, activities); } return { userActivities: next }; }); }, clearUserActivities: (userId) => { set((state) => { const next = new Map(state.userActivities); next.delete(userId); return { userActivities: next }; }); }, initActivities: (activityMap) => { set((state) => { const next = new Map(state.userActivities); for (const [userId, activities] of Object.entries(activityMap)) { if (activities.length > 0) { next.set(userId, activities); } else { next.delete(userId); } } return { userActivities: next }; }); }, setShowActivity: (show) => { set({ showActivity: show }); if (!show) { if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; } wsSendAll({ type: 'activity_update', activities: [] }); set({ myActivities: null }); } }, pushActivities: (activities) => { if (!get().showActivity) return; set({ myActivities: activities }); if (pushTimer) clearTimeout(pushTimer); pushTimer = setTimeout(() => { wsSendAll({ type: 'activity_update', activities }); pushTimer = null; }, 5000); }, setSourceActivities: (source, activities, opts) => { if (activities.length === 0) bySource.delete(source); else bySource.set(source, activities); const merged = Array.from(bySource.values()) .flat() .slice(0, ACTIVITY_LIMITS.MAX_ACTIVITIES_PER_USER); if (!opts?.immediate) { get().pushActivities(merged); return; } // Skips the 5s debounce. That delay exists to coalesce a chatty producer, // but a track change happens once every few minutes and stacking it on top // of the poll interval is what made everyone else see the previous song. if (!get().showActivity) return; if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; } set({ myActivities: merged }); wsSendAll({ type: 'activity_update', activities: merged }); }, reset: () => { if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; } bySource.clear(); set({ userActivities: new Map(), showActivity: true, myActivities: null }); }, }));