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
+13 -2
View File
@@ -473,9 +473,16 @@ function validateActivities(raw: unknown): Activity[] | null {
if (obj.assets && typeof obj.assets === 'object') {
const aObj = obj.assets as Record<string, unknown>;
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 <img src> 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<string, unknown>, userId: string): void {
const status = event.status as string;
@@ -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">
@@ -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<Activity['type'], string> = {
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 (
<div>
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
{VERB[primary.type]} {primary.name}
</span>
<div className="mt-2 flex gap-3 rounded-lg bg-surface-elevated/40 p-2.5">
{artSrc && (
<img
src={artSrc}
alt={primary.assets?.largeText ?? ''}
className="w-[60px] h-[60px] rounded object-cover flex-shrink-0"
referrerPolicy="no-referrer"
loading="lazy"
/>
)}
<div className="min-w-0 flex-1">
{primary.details && (
<div className="text-[13px] font-semibold text-txt-primary truncate">
{primary.details}
</div>
)}
{primary.state && (
<div className="text-[12px] text-txt-secondary truncate">{primary.state}</div>
)}
{duration > 0 ? (
<div className="mt-2">
<div className="h-[3px] rounded-full bg-interactive-muted overflow-hidden">
<div
className="h-full bg-txt-primary rounded-full"
style={{ width: `${progress * 100}%` }}
/>
</div>
<div className="flex justify-between text-[10px] text-txt-tertiary mt-1 tabular-nums">
<span>{formatClock(elapsed)}</span>
<span>{formatClock(duration)}</span>
</div>
</div>
) : start ? (
<div className="text-[11px] text-txt-tertiary mt-1 tabular-nums">
{formatClock(elapsed)} elapsed
</div>
) : null}
</div>
</div>
</div>
);
}