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:
@@ -1,4 +1,7 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { AudioManager } from '../audio/AudioManager';
|
||||
import { getSfxVolume } from '../utils/sfx';
|
||||
import { api } from '../api/client';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin, setMyUserIdForOrigin, resolveDmChannelId } from '../stores/spaceStore';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
@@ -285,6 +288,12 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
setVoiceUsers(channelId, userIds);
|
||||
}
|
||||
}
|
||||
if (event.voiceRoomStarts) {
|
||||
const vs = useVoiceStore.getState();
|
||||
for (const [channelId, startedAt] of Object.entries(event.voiceRoomStarts)) {
|
||||
vs.setVoiceRoomStart(channelId, startedAt);
|
||||
}
|
||||
}
|
||||
// Initialize activity data from ready payload
|
||||
if (event.userActivities) {
|
||||
useActivityStore.getState().initActivities(event.userActivities);
|
||||
@@ -574,14 +583,31 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'voice_state_update':
|
||||
case 'voice_state_update': {
|
||||
const vs = useVoiceStore.getState();
|
||||
if (event.action === 'join') {
|
||||
addVoiceUser(event.channelId, event.userId);
|
||||
if (event.startedAt) vs.setVoiceRoomStart(event.channelId, event.startedAt);
|
||||
} else {
|
||||
removeVoiceUser(event.channelId, event.userId);
|
||||
clearVoiceUserStatus(event.userId);
|
||||
// The server destroys an empty space room, so its clock is gone; drop
|
||||
// ours too or the next call would show the previous one's elapsed time.
|
||||
const remaining = useVoiceStore.getState().voiceUsers.get(event.channelId);
|
||||
if (!remaining || remaining.length === 0) vs.clearVoiceRoomStart(event.channelId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'soundboard_played':
|
||||
// Played locally by every client in the call rather than mixed into the
|
||||
// presser's microphone: no upstream bandwidth, no LiveKit track, and the
|
||||
// clip stays crisp instead of going through voice processing.
|
||||
void AudioManager.getInstance().playUrl(
|
||||
api.uploads.url(event.filename),
|
||||
{ volume: getSfxVolume() },
|
||||
);
|
||||
break;
|
||||
|
||||
case 'voice_status_update':
|
||||
setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened, event.isCameraOn, event.isScreenSharing);
|
||||
|
||||
Reference in New Issue
Block a user