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.
107 lines
3.9 KiB
TypeScript
107 lines
3.9 KiB
TypeScript
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>
|
|
);
|
|
}
|