chore(orbit): participants visible to guests, share button in session bar, guest icons

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-24 14:20:21 +02:00
parent 089774dc3e
commit 6c7f455e66
6 changed files with 134 additions and 47 deletions
+3 -36
View File
@@ -1,10 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Crown, UserMinus, ShieldOff, Copy, Check } from 'lucide-react';
import { Crown, User, UserMinus, ShieldOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { useAuthStore } from '../store/authStore';
import { kickOrbitParticipant, removeOrbitParticipant, buildOrbitShareLink } from '../utils/orbit';
import { kickOrbitParticipant, removeOrbitParticipant } from '../utils/orbit';
import ConfirmModal from './ConfirmModal';
interface Props {
@@ -27,25 +26,10 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
const { t } = useTranslation();
const state = useOrbitStore(s => s.state);
const role = useOrbitStore(s => s.role);
const sessionId = useOrbitStore(s => s.sessionId);
const popRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
const [confirm, setConfirm] = useState<{ user: string; mode: 'remove' | 'ban' } | null>(null);
const nowMs = Date.now();
const shareLink = role === 'host' && sessionId
? buildOrbitShareLink(useAuthStore.getState().getActiveServer()?.url ?? '', sessionId)
: null;
const onCopy = async () => {
if (!shareLink) return;
try {
await navigator.clipboard.writeText(shareLink);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch { /* silent */ }
};
// Close on outside click / Escape — unless a confirm dialog is open
// (otherwise outside-clicking the modal would dismiss the popover too,
// and re-opening would lose the in-flight confirm context).
@@ -92,24 +76,6 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
return createPortal(
<>
<div ref={popRef} className="orbit-participants-pop" style={style} role="menu">
{shareLink && (
<div className="orbit-participants-pop__invite">
<div className="orbit-participants-pop__invite-label">{t('orbit.participantsInviteLabel')}</div>
<div className="orbit-participants-pop__invite-row">
<code className="orbit-participants-pop__invite-link">{shareLink}</code>
<button
type="button"
className="orbit-participants-pop__invite-copy"
onClick={onCopy}
data-tooltip={copied ? t('orbit.tooltipCopied') : t('orbit.tooltipCopy')}
aria-label={t('orbit.ariaCopyLink')}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
</button>
</div>
</div>
)}
<div className="orbit-participants-pop__head">
{t('orbit.participantsCountLabel', { count: state.participants.length + 1 })}
</div>
@@ -126,6 +92,7 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
{state.participants.map(p => (
<div key={p.user} className="orbit-participants-pop__row">
<User size={13} />
<span className="orbit-participants-pop__name">{p.user}</span>
<span className="orbit-participants-pop__meta">{joinedFor(p.joinedAt, nowMs)}</span>
{role === 'host' && (
+24 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { X, RefreshCw, Shuffle, Settings2 } from 'lucide-react';
import { X, RefreshCw, Shuffle, Settings2, Share2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { usePlayerStore, songToTrack } from '../store/playerStore';
@@ -14,6 +14,7 @@ import { estimateLivePosition } from '../api/orbit';
import OrbitParticipantsPopover from './OrbitParticipantsPopover';
import OrbitExitModal from './OrbitExitModal';
import OrbitSettingsPopover from './OrbitSettingsPopover';
import OrbitSharePopover from './OrbitSharePopover';
import ConfirmModal from './ConfirmModal';
/**
@@ -46,9 +47,11 @@ export default function OrbitSessionBar() {
const [nowMs, setNowMs] = useState(() => Date.now());
const [peopleOpen, setPeopleOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [confirmLeave, setConfirmLeave] = useState(false);
const peopleBtnRef = useRef<HTMLButtonElement>(null);
const settingsBtnRef = useRef<HTMLButtonElement>(null);
const shareBtnRef = 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
@@ -179,6 +182,20 @@ export default function OrbitSessionBar() {
<Settings2 size={14} />
</button>
)}
{role === 'host' && (
<button
ref={shareBtnRef}
type="button"
className="orbit-bar__settings"
onClick={() => setShareOpen(v => !v)}
data-tooltip={t('orbit.shareTooltip')}
aria-haspopup="menu"
aria-expanded={shareOpen || undefined}
aria-label={t('orbit.shareTooltip')}
>
<Share2 size={14} />
</button>
)}
{showCatchUp && (
<button
type="button"
@@ -213,6 +230,12 @@ export default function OrbitSessionBar() {
onClose={() => setSettingsOpen(false)}
/>
)}
{shareOpen && (
<OrbitSharePopover
anchorRef={shareBtnRef}
onClose={() => setShareOpen(false)}
/>
)}
<OrbitExitModal />
<ConfirmModal
open={confirmLeave}
+84
View File
@@ -0,0 +1,84 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Copy, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { useAuthStore } from '../store/authStore';
import { buildOrbitShareLink } from '../utils/orbit';
interface Props {
anchorRef: React.RefObject<HTMLElement | null>;
onClose: () => void;
}
/**
* Host-only popover anchored below the share button in the Orbit bar.
* Surfaces the session invite link with a copy affordance. Lives on its
* own so the participants popover can stay focused on participants.
*/
export default function OrbitSharePopover({ anchorRef, onClose }: Props) {
const { t } = useTranslation();
const sessionId = useOrbitStore(s => s.sessionId);
const popRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
const shareLink = sessionId
? buildOrbitShareLink(useAuthStore.getState().getActiveServer()?.url ?? '', sessionId)
: null;
useEffect(() => {
const onDown = (e: MouseEvent) => {
const target = e.target as Node | null;
if (popRef.current?.contains(target)) return;
if (anchorRef.current?.contains(target)) 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]);
const onCopy = async () => {
if (!shareLink) return;
try {
await navigator.clipboard.writeText(shareLink);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch { /* silent */ }
};
if (!shareLink) return null;
const anchor = anchorRef.current?.getBoundingClientRect();
const style: React.CSSProperties = anchor
? {
position: 'fixed',
top: anchor.bottom + 12,
right: Math.max(8, window.innerWidth - anchor.right),
zIndex: 9999,
}
: { display: 'none' };
return createPortal(
<div ref={popRef} className="orbit-share-pop" style={style} role="menu">
<div className="orbit-share-pop__label">{t('orbit.participantsInviteLabel')}</div>
<div className="orbit-share-pop__row">
<code className="orbit-share-pop__link">{shareLink}</code>
<button
type="button"
className="orbit-share-pop__copy"
onClick={onCopy}
data-tooltip={copied ? t('orbit.tooltipCopied') : t('orbit.tooltipCopy')}
aria-label={t('orbit.ariaCopyLink')}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
</button>
</div>
</div>,
document.body,
);
}