diff --git a/packages/web/src/components/layout/ActivityPanel.tsx b/packages/web/src/components/layout/ActivityPanel.tsx
index ad95f7c1..39a25a92 100644
--- a/packages/web/src/components/layout/ActivityPanel.tsx
+++ b/packages/web/src/components/layout/ActivityPanel.tsx
@@ -1,9 +1,12 @@
import React, { useEffect, useMemo } from 'react';
import { useSocialStore } from '../../stores/socialStore';
import { useUIStore } from '../../stores/uiStore';
+import { useActivityStore } from '../../stores/activityStore';
import { Avatar } from '../ui/Avatar';
import { Username } from '../ui/Username';
+import { ActivityCard } from '../ui/ActivityCard';
import type { Friend } from '@backspace/shared';
+import { getPrimaryActivity } from '@backspace/shared/src/activities.js';
import { parseFederatedUsername } from '../../utils/identity';
export function ActivityPanel() {
@@ -11,16 +14,34 @@ export function ActivityPanel() {
const loadFriends = useSocialStore((s) => s.loadFriends);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const openUserProfile = useUIStore((s) => s.openUserProfile);
+ const userActivities = useActivityStore((s) => s.userActivities);
useEffect(() => {
loadFriends();
}, [loadFriends]);
- const { onlineFriends, offlineFriends } = useMemo(() => {
- const online = friends.filter(f => f.status !== 'offline');
- const offline = friends.filter(f => f.status === 'offline');
- return { onlineFriends: online, offlineFriends: offline };
- }, [friends]);
+ const { activeFriends, onlineFriends, offlineFriends } = useMemo(() => {
+ const active: Friend[] = [];
+ const online: Friend[] = [];
+ const offline: Friend[] = [];
+
+ for (const f of friends) {
+ if (f.status === 'offline') {
+ offline.push(f);
+ continue;
+ }
+ const activities = userActivities.get(f.homeUserId ?? f.id) ?? [];
+ const primary = getPrimaryActivity(activities);
+ // Active = has a non-custom activity (playing, listening, watching, streaming)
+ if (primary && primary.type !== 'custom') {
+ active.push(f);
+ } else {
+ online.push(f);
+ }
+ }
+
+ return { activeFriends: active, onlineFriends: online, offlineFriends: offline };
+ }, [friends, userActivities]);
if (!memberListOpen) return null;
@@ -52,9 +73,10 @@ export function ActivityPanel() {
);
};
- const renderFriend = (friend: Friend, isOffline = false) => {
+ const renderFriend = (friend: Friend, isOffline = false, isCompact = true) => {
const { baseName, domain } = parseFederatedUsername(friend.username);
const friendDisplayName = friend.displayName ?? baseName;
+ const activities = userActivities.get(friend.homeUserId ?? friend.id) ?? [];
return (
@{domain}
)}
- {!isOffline && friend.customStatus && (
- {friend.customStatus}
+ {!isOffline && (
+
)}
@@ -91,7 +117,7 @@ export function ActivityPanel() {
Active Now
- {onlineFriends.length === 0 && offlineFriends.length === 0 ? (
+ {activeFriends.length === 0 && onlineFriends.length === 0 && offlineFriends.length === 0 ? (
It's quiet for now...
@@ -100,6 +126,11 @@ export function ActivityPanel() {
) : (
<>
+ {activeFriends.length > 0 && (
+
+ {activeFriends.map(f => renderFriend(f, false, false))}
+
+ )}
{onlineFriends.length > 0 && (
diff --git a/packages/web/src/components/layout/MemberSidebar.tsx b/packages/web/src/components/layout/MemberSidebar.tsx
index 58dce3fb..4db5e4a0 100644
--- a/packages/web/src/components/layout/MemberSidebar.tsx
+++ b/packages/web/src/components/layout/MemberSidebar.tsx
@@ -2,8 +2,10 @@ import React, { useMemo } from 'react';
import type { MemberWithUser } from '@backspace/shared';
import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
+import { useActivityStore } from '../../stores/activityStore';
import { Avatar } from '../ui/Avatar';
import { Username } from '../ui/Username';
+import { ActivityCard } from '../ui/ActivityCard';
import { parseFederatedUsername } from '../../utils/identity';
/**
@@ -47,6 +49,7 @@ export function MemberSidebar() {
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const openUserProfile = useUIStore((s) => s.openUserProfile);
+ const userActivities = useActivityStore((s) => s.userActivities);
const space = spaces.find(s => s.id === currentSpaceId);
const ownerId = space?.ownerId;
@@ -122,8 +125,12 @@ export function MemberSidebar() {
{domain && !isOffline && (
@{domain}
)}
- {!isOffline && member.user.customStatus && (
- {member.user.customStatus}
+ {!isOffline && (
+
)}
diff --git a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx
index e5b4e9f2..cb6833a5 100644
--- a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx
+++ b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx
@@ -11,6 +11,7 @@ import type { User, UserStatus, AvatarColor } from '@backspace/shared';
import type { FederationOpResult } from '../../../utils/federationOps';
import { isElectron } from '../../../platform/platform';
import { Toggle } from '../../ui/Toggle';
+import { useActivityStore } from '../../../stores/activityStore';
function AutoLaunchSettings() {
@@ -139,6 +140,7 @@ export function AccountPanel() {
const instances = useInstanceStore((s) => s.instances);
const changePassword = useAuthStore((s) => s.changePassword);
+ const showActivity = useActivityStore((s) => s.showActivity);
if (!user) return null;
@@ -775,6 +777,30 @@ export function AccountPanel() {
)}
+ {/* ── Activity Status ── */}
+
+
Activity Status
+
+
+
+
Share Activity Status
+
Allow others to see what you're up to
+
+
{
+ try {
+ await api.users.update({ showActivity: enabled });
+ useActivityStore.getState().setShowActivity(enabled);
+ } catch (err) {
+ console.error('Failed to update activity visibility:', err);
+ }
+ }}
+ />
+
+
+
+
{/* ── Danger Zone ── */}
Danger Zone
diff --git a/packages/web/src/components/ui/ActivityCard.tsx b/packages/web/src/components/ui/ActivityCard.tsx
new file mode 100644
index 00000000..e611aec3
--- /dev/null
+++ b/packages/web/src/components/ui/ActivityCard.tsx
@@ -0,0 +1,122 @@
+import type { Activity } from '@backspace/shared';
+import { getPrimaryActivity } from '@backspace/shared/src/activities.js';
+
+interface ActivityCardProps {
+ activities: Activity[];
+ compact?: boolean;
+ fallbackCustomStatus?: string | null;
+}
+
+function formatElapsed(startMs: number): string {
+ const elapsed = Date.now() - startMs;
+ const minutes = Math.floor(elapsed / 60000);
+ const hours = Math.floor(minutes / 60);
+ if (hours > 0) return `${hours}h ${minutes % 60}m`;
+ return `${minutes}m`;
+}
+
+function ActivityIcon({ type }: { type: Activity['type'] }) {
+ switch (type) {
+ case 'playing':
+ return
PLAYING;
+ case 'listening':
+ return
LISTENING;
+ case 'watching':
+ return
WATCHING;
+ case 'streaming':
+ return
LIVE;
+ default:
+ return null;
+ }
+}
+
+function CompactActivity({ activity }: { activity: Activity }) {
+ const label = activity.type === 'custom'
+ ? activity.name
+ : activity.type === 'playing'
+ ? `Playing ${activity.name}`
+ : activity.type === 'listening'
+ ? activity.details ?? activity.name
+ : activity.type === 'watching'
+ ? `Watching ${activity.name}`
+ : activity.name;
+
+ return (
+
+ );
+}
+
+function FullActivity({ activity }: { activity: Activity }) {
+ if (activity.type === 'custom') {
+ return (
+
+ {activity.name}
+
+ );
+ }
+
+ const typeLabel = activity.type === 'playing' ? 'Playing'
+ : activity.type === 'listening' ? 'Listening to'
+ : activity.type === 'watching' ? 'Watching'
+ : 'Streaming';
+
+ return (
+
+
+ {typeLabel}
+
+
+ {activity.assets?.largeImage && (
+

+ )}
+
+
+ {activity.name}
+
+ {activity.details && (
+
{activity.details}
+ )}
+ {activity.state && (
+
{activity.state}
+ )}
+ {activity.timestamps?.start && (
+
+ {formatElapsed(activity.timestamps.start)} elapsed
+
+ )}
+
+ {activity.assets?.smallImage && (
+

+ )}
+
+
+ );
+}
+
+export function ActivityCard({ activities, compact = false, fallbackCustomStatus }: ActivityCardProps) {
+ const primary = getPrimaryActivity(activities);
+
+ if (!primary) {
+ if (fallbackCustomStatus) {
+ return
{fallbackCustomStatus}
;
+ }
+ return null;
+ }
+
+ if (compact) {
+ return
;
+ }
+
+ return
;
+}
diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts
index 4522e181..febf3775 100644
--- a/packages/web/src/hooks/useWebSocket.ts
+++ b/packages/web/src/hooks/useWebSocket.ts
@@ -11,6 +11,7 @@ import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../utils/voice'
import { registerSelfId } from '../utils/identity';
import { getActiveRoom } from './useLiveKit';
import { useUIStore } from '../stores/uiStore';
+import { useActivityStore } from '../stores/activityStore';
// ─── Connection state ─────────────────────────────────────────────────────────
@@ -205,6 +206,14 @@ function handleEvent(origin: string, event: ServerEvent): void {
setVoiceUsers(channelId, userIds);
}
}
+ // Initialize activity data from ready payload
+ if (event.userActivities) {
+ useActivityStore.getState().initActivities(event.userActivities);
+ }
+ if (event.user.showActivity !== undefined) {
+ useActivityStore.setState({ showActivity: event.user.showActivity });
+ }
+
// Populate voice user statuses (mute/deafen/camera/screenshare) from server
if (event.voiceUserStates) {
for (const [uid, status] of Object.entries(event.voiceUserStates)) {
@@ -369,6 +378,9 @@ function handleEvent(origin: string, event: ServerEvent): void {
case 'presence_update':
updateMemberPresence(event.userId, event.status);
useSocialStore.getState().updateFriendPresence(event.userId, event.status);
+ if (event.activities) {
+ useActivityStore.getState().setUserActivities(event.userId, event.activities);
+ }
break;
case 'user_updated': {
diff --git a/packages/web/src/stores/activityStore.ts b/packages/web/src/stores/activityStore.ts
new file mode 100644
index 00000000..6c20226c
--- /dev/null
+++ b/packages/web/src/stores/activityStore.ts
@@ -0,0 +1,74 @@
+import { create } from 'zustand';
+import type { Activity } from '@backspace/shared';
+import { wsSend } from '../hooks/useWebSocket';
+
+let pushTimer: ReturnType
| null = null;
+
+interface ActivityState {
+ userActivities: Map;
+ showActivity: boolean;
+
+ setUserActivities: (userId: string, activities: Activity[]) => void;
+ clearUserActivities: (userId: string) => void;
+ initActivities: (activityMap: Record) => void;
+ setShowActivity: (show: boolean) => void;
+ pushActivities: (activities: Activity[]) => void;
+ reset: () => void;
+}
+
+export const useActivityStore = create((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();
+ 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 });
+ },
+}));
diff --git a/packages/web/src/stores/authStore.ts b/packages/web/src/stores/authStore.ts
index e2cc341e..eb051d54 100644
--- a/packages/web/src/stores/authStore.ts
+++ b/packages/web/src/stores/authStore.ts
@@ -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((set, get) => ({