exp(orbit): participants list, kick flow, exit modals

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-23 01:13:00 +02:00
parent 9e1256e200
commit dc82e49bd1
5 changed files with 359 additions and 3 deletions
+59
View File
@@ -0,0 +1,59 @@
import { createPortal } from 'react-dom';
import { useOrbitStore } from '../store/orbitStore';
import { leaveOrbitSession } from '../utils/orbit';
/**
* Orbit — exit notification modal.
*
* Shown when:
* - `phase === 'ended'` (host closed the session; guest sees it)
* - `phase === 'error' && errorMessage === 'kicked'` (host removed us)
*
* "OK" cleans up the guest-side outbox + resets the local store.
*/
export default function OrbitExitModal() {
const phase = useOrbitStore(s => s.phase);
const errorMessage = useOrbitStore(s => s.errorMessage);
const role = useOrbitStore(s => s.role);
const sessionName = useOrbitStore(s => s.state?.name);
const hostName = useOrbitStore(s => s.state?.host);
const isEnded = phase === 'ended';
const isKicked = phase === 'error' && errorMessage === 'kicked';
if (!isEnded && !isKicked) return null;
const title = isKicked
? 'You were removed from the session'
: 'The host ended the session';
const body = isKicked
? `@${hostName ?? 'host'} removed you from "${sessionName ?? 'the session'}".`
: `"${sessionName ?? 'The session'}" has ended. Hope you had fun.`;
const onOk = async () => {
try {
if (role === 'guest') await leaveOrbitSession();
else useOrbitStore.getState().reset();
} catch {
useOrbitStore.getState().reset();
}
};
return createPortal(
<div
className="modal-overlay orbit-exit-overlay"
onClick={e => { if (e.target === e.currentTarget) onOk(); }}
role="dialog"
aria-modal="true"
aria-labelledby="orbit-exit-title"
>
<div className="modal-content orbit-exit-modal">
<h3 id="orbit-exit-title" className="orbit-exit-modal__title">{title}</h3>
<p className="orbit-exit-modal__body">{body}</p>
<div className="orbit-exit-modal__actions">
<button type="button" className="btn btn-primary" onClick={onOk}>OK</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,98 @@
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { Crown, UserMinus } from 'lucide-react';
import { useOrbitStore } from '../store/orbitStore';
import { kickOrbitParticipant } from '../utils/orbit';
interface Props {
/** Anchor — we position the popover directly below its bottom-right. */
anchorRef: React.RefObject<HTMLElement | null>;
onClose: () => void;
}
function joinedFor(fromMs: number, nowMs: number): string {
const sec = Math.max(0, Math.round((nowMs - fromMs) / 1000));
if (sec < 60) return `${sec}s`;
const m = Math.floor(sec / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
const rm = m % 60;
return `${h}h${rm.toString().padStart(2, '0')}`;
}
export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props) {
const state = useOrbitStore(s => s.state);
const role = useOrbitStore(s => s.role);
const popRef = useRef<HTMLDivElement>(null);
const nowMs = Date.now();
// Close on outside click / Escape.
useEffect(() => {
const onDown = (e: MouseEvent) => {
const t = e.target as Node | null;
if (popRef.current?.contains(t)) return;
if (anchorRef.current?.contains(t)) return;
onClose();
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [anchorRef, onClose]);
if (!state) return null;
const anchor = anchorRef.current?.getBoundingClientRect();
const style: React.CSSProperties = anchor
? {
position: 'fixed',
top: anchor.bottom + 6,
left: Math.max(8, anchor.left - 100),
zIndex: 9999,
}
: { display: 'none' };
const onKick = (username: string) => {
void kickOrbitParticipant(username);
};
return createPortal(
<div ref={popRef} className="orbit-participants-pop" style={style} role="menu">
<div className="orbit-participants-pop__head">
{state.participants.length + 1} in session
</div>
<div className="orbit-participants-pop__row orbit-participants-pop__row--host">
<Crown size={13} />
<span className="orbit-participants-pop__name">@{state.host}</span>
<span className="orbit-participants-pop__meta">host</span>
</div>
{state.participants.length === 0 && (
<div className="orbit-participants-pop__empty">No guests yet</div>
)}
{state.participants.map(p => (
<div key={p.user} className="orbit-participants-pop__row">
<span className="orbit-participants-pop__name">@{p.user}</span>
<span className="orbit-participants-pop__meta">{joinedFor(p.joinedAt, nowMs)}</span>
{role === 'host' && (
<button
type="button"
className="orbit-participants-pop__kick"
onClick={() => onKick(p.user)}
data-tooltip="Remove from session"
aria-label={`Remove @${p.user}`}
>
<UserMinus size={12} />
</button>
)}
</div>
))}
</div>,
document.body,
);
}
+34 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { X, RefreshCw } from 'lucide-react';
import { useOrbitStore } from '../store/orbitStore';
import { usePlayerStore, songToTrack } from '../store/playerStore';
@@ -10,6 +10,8 @@ import {
} from '../utils/orbit';
import { ORBIT_SHUFFLE_INTERVAL_MS } from '../utils/orbit';
import { estimateLivePosition } from '../api/orbit';
import OrbitParticipantsPopover from './OrbitParticipantsPopover';
import OrbitExitModal from './OrbitExitModal';
/**
* Orbit — top-strip session indicator.
@@ -36,7 +38,10 @@ export default function OrbitSessionBar() {
const state = useOrbitStore(s => s.state);
const role = useOrbitStore(s => s.role);
const phase = useOrbitStore(s => s.phase);
const errorMessage = useOrbitStore(s => s.errorMessage);
const [nowMs, setNowMs] = useState(() => Date.now());
const [peopleOpen, setPeopleOpen] = useState(false);
const peopleBtnRef = useRef<HTMLButtonElement>(null);
// Second-level tick just for the shuffle countdown + drift readout —
// the store itself only ticks at 2.5 s which is too coarse for a smooth
@@ -47,7 +52,15 @@ export default function OrbitSessionBar() {
return () => window.clearInterval(id);
}, [state, phase]);
if (!state || (phase !== 'active' && phase !== 'ended')) return null;
// Bar is visible while active, ended (pre-ack), or explicitly kicked.
const shouldShowBar = !!state && (
phase === 'active'
|| phase === 'ended'
|| (phase === 'error' && errorMessage === 'kicked')
);
if (!shouldShowBar || !state) return (
<OrbitExitModal />
);
const untilShuffle = Math.max(0, (state.lastShuffle + ORBIT_SHUFFLE_INTERVAL_MS) - nowMs);
@@ -110,7 +123,17 @@ export default function OrbitSessionBar() {
<span className="orbit-bar__dot" aria-hidden="true" />
<span className="orbit-bar__name">{state.name}</span>
<span className="orbit-bar__sep">·</span>
<span className="orbit-bar__count">{participantCount}/{state.maxUsers}</span>
<button
ref={peopleBtnRef}
type="button"
className="orbit-bar__count"
onClick={() => setPeopleOpen(v => !v)}
data-tooltip="Participants"
aria-haspopup="menu"
aria-expanded={peopleOpen || undefined}
>
{participantCount}/{state.maxUsers}
</button>
<span className="orbit-bar__sep">·</span>
<span className="orbit-bar__host">host: @{state.host}</span>
</div>
@@ -143,6 +166,14 @@ export default function OrbitSessionBar() {
<X size={15} />
</button>
</div>
{peopleOpen && (
<OrbitParticipantsPopover
anchorRef={peopleBtnRef}
onClose={() => setPeopleOpen(false)}
/>
)}
<OrbitExitModal />
</div>
);
}