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 ( +
+ + {label} +
+ ); +} + +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.assets.largeText + )} +
+
+ {activity.name} +
+ {activity.details && ( +
{activity.details}
+ )} + {activity.state && ( +
{activity.state}
+ )} + {activity.timestamps?.start && ( +
+ {formatElapsed(activity.timestamps.start)} elapsed +
+ )} +
+ {activity.assets?.smallImage && ( + {activity.assets.smallText + )} +
+
+ ); +} + +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) => ({