feat(activity): show the current activity on the profile card

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 <img src>: a client could point them at a host it controls and
harvest the IP of everyone opening that profile.
This commit is contained in:
2026-08-31 12:05:01 -03:00
parent bfe62d7078
commit d7da0ff203
3 changed files with 131 additions and 2 deletions
@@ -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() {
</div>
)}
{/* Current activity — the "Listening to Spotify" block */}
<ProfileActivity activities={activities} />
{/* Member Since */}
<div>
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">