feat: soundboard, account menu, and call timer
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Soundboard: the trigger travels over the WebSocket and every client in the call plays the clip locally, instead of mixing it into the presser's microphone or publishing a LiveKit track. No upstream bandwidth, no media stack changes, and the clip is not degraded by voice processing. Fan-out uses a new sendToRoomParticipants rather than sendToRoom: the latter broadcasts a space room to the whole space, which is right for the presence the sidebar shows and wrong for anything audible. The cooldown is enforced server-side — a client-side one only slows down people not trying to abuse it, and a soundboard is the easiest thing here to turn into a weapon. Playing is open to anyone in the call; deciding what the buttons are needs MANAGE_SPACE. Account menu: the name in the user bar had cursor-pointer and no handler, so the interface was already promising a click that did nothing. Offers profile, status and copy-id — not the Clips or account switching the reference design shows, which would be dead UI here. Call timer: startedAt comes from the server, so a late joiner sees the call's age rather than their own arrival. Empty space rooms are destroyed already, which is what makes the next call start from zero — no reset logic needed.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { UserStatus } from '@backspace/shared';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { useT, type TranslationKey } from '../../i18n';
|
||||
|
||||
interface AccountMenuProps {
|
||||
onClose: () => void;
|
||||
onEditProfile: () => void;
|
||||
}
|
||||
|
||||
const STATUSES: { value: UserStatus; key: TranslationKey; dot: string }[] = [
|
||||
{ value: 'online', key: 'accountMenu.status.online', dot: 'bg-status-online' },
|
||||
{ value: 'idle', key: 'accountMenu.status.idle', dot: 'bg-status-idle' },
|
||||
{ value: 'dnd', key: 'accountMenu.status.dnd', dot: 'bg-status-dnd' },
|
||||
// 'offline' chosen deliberately is what other clients call invisible.
|
||||
{ value: 'offline', key: 'accountMenu.status.offline', dot: 'bg-txt-tertiary' },
|
||||
];
|
||||
|
||||
export function AccountMenu({ onClose, onEditProfile }: AccountMenuProps) {
|
||||
const t = useT();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const updateProfile = useAuthStore((s) => s.updateProfile);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointer = (e: MouseEvent | TouchEvent) => {
|
||||
if (!menuRef.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
|
||||
};
|
||||
// touchstart alongside mousedown: iOS Safari does not reliably synthesise
|
||||
// mousedown from a tap, matching what the other popovers here do.
|
||||
document.addEventListener('mousedown', handlePointer);
|
||||
document.addEventListener('touchstart', handlePointer);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointer);
|
||||
document.removeEventListener('touchstart', handlePointer);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const handleStatus = async (status: UserStatus) => {
|
||||
if (status === (user.status ?? 'online')) return onClose();
|
||||
try {
|
||||
await updateProfile({ status });
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyId = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(user.id);
|
||||
setCopied(true);
|
||||
// Left open on purpose: the confirmation is the only feedback, and
|
||||
// closing immediately would hide it.
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard is unavailable over plain http or without permission.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
className="absolute bottom-full left-2 right-2 mb-2 z-[200] glass rounded-xl overflow-hidden py-1.5 shadow-xl"
|
||||
>
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => { onEditProfile(); onClose(); }}
|
||||
className="w-full px-3 py-2 flex items-center gap-2.5 text-[13.5px] text-txt-secondary hover:bg-interactive-hover hover:text-txt-primary transition-colors"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25ZM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83Z" />
|
||||
</svg>
|
||||
{t('accountMenu.editProfile')}
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-white/[0.06] my-1.5 mx-2" />
|
||||
<div className="px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-txt-tertiary">
|
||||
{t('accountMenu.status')}
|
||||
</div>
|
||||
{STATUSES.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
role="menuitemradio"
|
||||
aria-checked={(user.status ?? 'online') === option.value}
|
||||
onClick={() => void handleStatus(option.value)}
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2.5 text-[13.5px] text-txt-secondary hover:bg-interactive-hover hover:text-txt-primary transition-colors"
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${option.dot}`} />
|
||||
<span className="flex-1 text-left">{t(option.key)}</span>
|
||||
{(user.status ?? 'online') === option.value && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="h-px bg-white/[0.06] my-1.5 mx-2" />
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => void handleCopyId()}
|
||||
className="w-full px-3 py-2 flex items-center gap-2.5 text-[13.5px] text-txt-secondary hover:bg-interactive-hover hover:text-txt-primary transition-colors"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M16 1H4a2 2 0 0 0-2 2v14h2V3h12V1Zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2Zm0 16H8V7h11v14Z" />
|
||||
</svg>
|
||||
{copied ? t('accountMenu.copied') : t('accountMenu.copyId')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user