Files
backspace/packages/web/src/components/layout/AccountMenu.tsx
T
devsyncwrld f5451e1b14
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
feat: soundboard, account menu, and call timer
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.
2026-08-31 13:45:18 -03:00

121 lines
4.9 KiB
TypeScript

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>
);
}