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
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.
121 lines
4.9 KiB
TypeScript
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>
|
|
);
|
|
}
|