From d7da0ff203da3891497ce42d811746507b4abf34 Mon Sep 17 00:00:00 2001 From: devsyncwrld Date: Mon, 31 Aug 2026 12:05:01 -0300 Subject: [PATCH] feat(activity): show the current activity on the profile card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The activity pipeline was already complete end to end — Activity type, store, WS broadcast, server validation, presence relay, and an ActivityCard used by four list surfaces — but the profile card rendered none of it, which is the 'Listening to Spotify' block the design calls for. Add ProfileActivity: richer than ActivityCard because the card has room for artwork, track and artist, so it reads details/state/assets. All optional, so it degrades to the bare name that today's process-based detector supplies. Also scheme-check activity image assets server-side. activity.url was already restricted to http(s) but assets.largeImage/smallImage were only length-checked — an asymmetry that was harmless while nothing rendered them, and is not once they become : a client could point them at a host it controls and harvest the IP of everyone opening that profile. --- packages/server/src/ws/events.ts | 15 ++- .../components/modals/UserProfileModal.tsx | 12 ++ .../web/src/components/ui/ProfileActivity.tsx | 106 ++++++++++++++++++ 3 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 packages/web/src/components/ui/ProfileActivity.tsx diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 54a1765d..cd533fc1 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -473,9 +473,16 @@ function validateActivities(raw: unknown): Activity[] | null { if (obj.assets && typeof obj.assets === 'object') { const aObj = obj.assets as Record; const assets: ActivityAssets = {}; - if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.largeImage = aObj.largeImage; + // Image assets are rendered as by clients, so they get the same + // scheme check `url` above already has. Without it a client could point + // them at a host it controls and harvest the IP of everyone who opens + // that profile — and data: URIs would smuggle payloads through a field + // only length-checked. + if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH + && isHttpUrl(aObj.largeImage)) assets.largeImage = aObj.largeImage; if (typeof aObj.largeText === 'string' && aObj.largeText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.largeText = aObj.largeText; - if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.smallImage = aObj.smallImage; + if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH + && isHttpUrl(aObj.smallImage)) assets.smallImage = aObj.smallImage; if (typeof aObj.smallText === 'string' && aObj.smallText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.smallText = aObj.smallText; if (Object.keys(assets).length > 0) activity.assets = assets; } @@ -485,6 +492,10 @@ function validateActivities(raw: unknown): Activity[] | null { return validated; } +function isHttpUrl(value: string): boolean { + return value.startsWith('https://') || value.startsWith('http://'); +} + function handlePresenceUpdate(event: Record, userId: string): void { const status = event.status as string; diff --git a/packages/web/src/components/modals/UserProfileModal.tsx b/packages/web/src/components/modals/UserProfileModal.tsx index 9cb4e92b..c5b6c7f5 100644 --- a/packages/web/src/components/modals/UserProfileModal.tsx +++ b/packages/web/src/components/modals/UserProfileModal.tsx @@ -3,6 +3,8 @@ import { useNavigate } from 'react-router-dom'; import ReactMarkdown from 'react-markdown'; import type { User } from '@backspace/shared'; import { Avatar } from '../ui/Avatar'; +import { ProfileActivity } from '../ui/ProfileActivity'; +import { useActivityStore } from '../../stores/activityStore'; import { Username } from '../ui/Username'; import { useUIStore } from '../../stores/uiStore'; import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore'; @@ -149,6 +151,13 @@ export function UserProfileModal() { // Banner — use correct API client for remote users const profileApi = getApiForOrigin(userOrigin); + // Keyed by home id, matching every other activity consumer (ActivityPanel, + // MemberSidebar), so federated users resolve to the same record. The `?? []` + // stays OUTSIDE the selector: building it inside would hand zustand a fresh + // array reference every render and spin. + const activityList = useActivityStore((s) => s.userActivities.get(user.homeUserId ?? user.id)); + const activities = activityList ?? []; + const bannerSrc = user.banner ? (user.banner.startsWith('http') ? user.banner : profileApi.uploads.url(user.banner)) : null; @@ -353,6 +362,9 @@ export function UserProfileModal() { )} + {/* Current activity — the "Listening to Spotify" block */} + + {/* Member Since */}
diff --git a/packages/web/src/components/ui/ProfileActivity.tsx b/packages/web/src/components/ui/ProfileActivity.tsx new file mode 100644 index 00000000..0fac3121 --- /dev/null +++ b/packages/web/src/components/ui/ProfileActivity.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react'; +import type { Activity } from '@backspace/shared'; +import { getPrimaryActivity } from '@backspace/shared/src/activities.js'; + +interface ProfileActivityProps { + activities: Activity[]; +} + +const VERB: Record = { + playing: 'Playing', + listening: 'Listening to', + watching: 'Watching', + streaming: 'Streaming', + custom: '', +}; + +function formatClock(ms: number): string { + const total = Math.max(0, Math.floor(ms / 1000)); + const minutes = Math.floor(total / 60); + const seconds = total % 60; + const hours = Math.floor(minutes / 60); + if (hours > 0) return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +/** + * The activity block on the profile card — the "Listening to Spotify" panel. + * + * Deliberately richer than `ActivityCard` (which renders name + elapsed for + * compact list rows): here there is room for the artwork, the track and the + * artist, so it reads `details`, `state` and `assets` too. Every one of those + * is optional and the block degrades to just the name, which is all today's + * process-based detector supplies — the extra fields are what a Spotify + * producer would fill in. + */ +export function ProfileActivity({ activities }: ProfileActivityProps) { + const primary = getPrimaryActivity(activities); + const start = primary?.timestamps?.start; + const end = primary?.timestamps?.end; + + // Re-render once a second only while there is a clock to advance. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!start) return; + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [start]); + + if (!primary || primary.type === 'custom') return null; + + const elapsed = start ? now - start : 0; + const duration = start && end ? end - start : 0; + const progress = duration > 0 ? Math.min(Math.max(elapsed / duration, 0), 1) : 0; + + // The server restricts asset images to http(s); this mirrors that so a + // record stored before that check cannot inject another scheme. + const art = primary.assets?.largeImage; + const artSrc = art && (art.startsWith('https://') || art.startsWith('http://')) ? art : null; + + return ( +
+ + {VERB[primary.type]} {primary.name} + +
+ {artSrc && ( + {primary.assets?.largeText + )} +
+ {primary.details && ( +
+ {primary.details} +
+ )} + {primary.state && ( +
{primary.state}
+ )} + {duration > 0 ? ( +
+
+
+
+
+ {formatClock(elapsed)} + {formatClock(duration)} +
+
+ ) : start ? ( +
+ {formatClock(elapsed)} elapsed +
+ ) : null} +
+
+
+ ); +}