feat: add frontend Rich Presence — activityStore, ActivityCard, sidebar upgrades, settings toggle

- Task 7: Create activityStore (Zustand) with Map<userId, Activity[]>, showActivity toggle,
  debounced pushActivities, and reset on logout
- Task 8: Wire WS integration — presence_update propagates activities to store,
  ready payload initializes userActivities and showActivity (via setState to avoid side effects)
- Task 9: Create ActivityCard component with compact/full modes, type-colored labels,
  elapsed time display, asset images, and fallback to customStatus
- Task 10: Upgrade ActivityPanel to three groups — active friends (full cards, no header),
  online friends (compact), offline friends — using getPrimaryActivity for grouping
- Task 11: Upgrade MemberSidebar to show compact ActivityCards instead of raw customStatus
- Task 12: Add showActivity toggle in AccountPanel settings (Activity Status section)
  with API persistence and store sync
This commit is contained in:
Jannis Braun
2026-03-21 01:55:17 +01:00
parent 9403ded8da
commit 1f4e149ee1
7 changed files with 285 additions and 11 deletions
+74
View File
@@ -0,0 +1,74 @@
import { create } from 'zustand';
import type { Activity } from '@backspace/shared';
import { wsSend } from '../hooks/useWebSocket';
let pushTimer: ReturnType<typeof setTimeout> | null = null;
interface ActivityState {
userActivities: Map<string, Activity[]>;
showActivity: boolean;
setUserActivities: (userId: string, activities: Activity[]) => void;
clearUserActivities: (userId: string) => void;
initActivities: (activityMap: Record<string, Activity[]>) => void;
setShowActivity: (show: boolean) => void;
pushActivities: (activities: Activity[]) => void;
reset: () => void;
}
export const useActivityStore = create<ActivityState>((set, get) => ({
userActivities: new Map(),
showActivity: true,
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) => {
const next = new Map<string, Activity[]>();
for (const [userId, activities] of Object.entries(activityMap)) {
if (activities.length > 0) {
next.set(userId, activities);
}
}
set({ userActivities: next });
},
setShowActivity: (show) => {
set({ showActivity: show });
if (!show) {
if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; }
wsSend({ type: 'activity_update', activities: [] });
}
},
pushActivities: (activities) => {
if (!get().showActivity) return;
if (pushTimer) clearTimeout(pushTimer);
pushTimer = setTimeout(() => {
wsSend({ type: 'activity_update', activities });
pushTimer = null;
}, 5000);
},
reset: () => {
if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; }
set({ userActivities: new Map(), showActivity: true });
},
}));
+2
View File
@@ -6,6 +6,7 @@ import { useSpaceStore } from './spaceStore';
import { useSocialStore } from './socialStore';
import { useVoiceStore } from './voiceStore';
import { useInstanceStore } from './instanceStore';
import { useActivityStore } from './activityStore';
import { syncProfileUpdateToRemotes } from '../utils/profileSync';
import { changePasswordOnRemotes, deleteAccountOnRemotes, type FederationOpResult } from '../utils/federationOps';
import { clearSelfIds } from '../utils/identity';
@@ -35,6 +36,7 @@ function resetUserStores() {
useSocialStore.getState().reset();
useVoiceStore.getState().resetSession();
useInstanceStore.getState().reset();
useActivityStore.getState().reset();
}
export const useAuthStore = create<AuthState>((set, get) => ({