feat(activity): fan out activity updates to all connected instances

Switch pushActivities and setShowActivity from wsSend (home-only) to
wsSendAll (all instances). Add myActivities cache for re-push on late
connections.
This commit is contained in:
Jannis Braun
2026-03-24 21:55:45 +01:00
parent 38ffc214f0
commit 9705304be9
2 changed files with 93 additions and 4 deletions
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock useWebSocket before importing activityStore
const mockWsSend = vi.fn();
const mockWsSendAll = vi.fn();
vi.mock('../hooks/useWebSocket', () => ({
wsSend: (...args: unknown[]) => mockWsSend(...args),
wsSendAll: (...args: unknown[]) => mockWsSendAll(...args),
}));
import { useActivityStore } from './activityStore';
import type { Activity } from '@backspace/shared';
const GAME_ACTIVITY: Activity = {
type: 'playing',
name: 'Minecraft',
timestamps: { start: Date.now() },
};
beforeEach(() => {
vi.clearAllMocks();
useActivityStore.getState().reset();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('activityStore fan-out', () => {
it('pushActivities caches myActivities immediately before debounce', () => {
useActivityStore.getState().pushActivities([GAME_ACTIVITY]);
// myActivities should be set immediately (before the 5s debounce)
expect(useActivityStore.getState().myActivities).toEqual([GAME_ACTIVITY]);
// wsSendAll should NOT have been called yet (debounce hasn't fired)
expect(mockWsSendAll).not.toHaveBeenCalled();
});
it('pushActivities calls wsSendAll (not wsSend) after debounce', () => {
useActivityStore.getState().pushActivities([GAME_ACTIVITY]);
vi.advanceTimersByTime(5000);
expect(mockWsSendAll).toHaveBeenCalledWith({
type: 'activity_update',
activities: [GAME_ACTIVITY],
});
expect(mockWsSend).not.toHaveBeenCalled();
});
it('setShowActivity(false) fans out empty activities via wsSendAll and clears myActivities', () => {
// First set some activities
useActivityStore.getState().pushActivities([GAME_ACTIVITY]);
expect(useActivityStore.getState().myActivities).toEqual([GAME_ACTIVITY]);
// Now disable
useActivityStore.getState().setShowActivity(false);
expect(mockWsSendAll).toHaveBeenCalledWith({
type: 'activity_update',
activities: [],
});
expect(useActivityStore.getState().myActivities).toBeNull();
});
it('reset clears myActivities', () => {
useActivityStore.getState().pushActivities([GAME_ACTIVITY]);
expect(useActivityStore.getState().myActivities).toEqual([GAME_ACTIVITY]);
useActivityStore.getState().reset();
expect(useActivityStore.getState().myActivities).toBeNull();
});
it('pushActivities does nothing when showActivity is false', () => {
useActivityStore.getState().setShowActivity(false);
vi.clearAllMocks();
useActivityStore.getState().pushActivities([GAME_ACTIVITY]);
expect(useActivityStore.getState().myActivities).toBeNull();
vi.advanceTimersByTime(5000);
expect(mockWsSendAll).not.toHaveBeenCalled();
});
});
+8 -4
View File
@@ -1,12 +1,13 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { Activity } from '@backspace/shared'; import type { Activity } from '@backspace/shared';
import { wsSend } from '../hooks/useWebSocket'; import { wsSendAll } from '../hooks/useWebSocket';
let pushTimer: ReturnType<typeof setTimeout> | null = null; let pushTimer: ReturnType<typeof setTimeout> | null = null;
interface ActivityState { interface ActivityState {
userActivities: Map<string, Activity[]>; userActivities: Map<string, Activity[]>;
showActivity: boolean; showActivity: boolean;
myActivities: Activity[] | null;
setUserActivities: (userId: string, activities: Activity[]) => void; setUserActivities: (userId: string, activities: Activity[]) => void;
clearUserActivities: (userId: string) => void; clearUserActivities: (userId: string) => void;
@@ -19,6 +20,7 @@ interface ActivityState {
export const useActivityStore = create<ActivityState>((set, get) => ({ export const useActivityStore = create<ActivityState>((set, get) => ({
userActivities: new Map(), userActivities: new Map(),
showActivity: true, showActivity: true,
myActivities: null,
setUserActivities: (userId, activities) => { setUserActivities: (userId, activities) => {
set((state) => { set((state) => {
@@ -58,21 +60,23 @@ export const useActivityStore = create<ActivityState>((set, get) => ({
set({ showActivity: show }); set({ showActivity: show });
if (!show) { if (!show) {
if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; } if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; }
wsSend({ type: 'activity_update', activities: [] }); wsSendAll({ type: 'activity_update', activities: [] });
set({ myActivities: null });
} }
}, },
pushActivities: (activities) => { pushActivities: (activities) => {
if (!get().showActivity) return; if (!get().showActivity) return;
set({ myActivities: activities });
if (pushTimer) clearTimeout(pushTimer); if (pushTimer) clearTimeout(pushTimer);
pushTimer = setTimeout(() => { pushTimer = setTimeout(() => {
wsSend({ type: 'activity_update', activities }); wsSendAll({ type: 'activity_update', activities });
pushTimer = null; pushTimer = null;
}, 5000); }, 5000);
}, },
reset: () => { reset: () => {
if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; } if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; }
set({ userActivities: new Map(), showActivity: true }); set({ userActivities: new Map(), showActivity: true, myActivities: null });
}, },
})); }));