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:
@@ -168,6 +168,10 @@ export function handleClientEvent(
|
||||
case 'voice_join':
|
||||
handleVoiceJoin(event, userId, ws);
|
||||
break;
|
||||
case 'soundboard_play':
|
||||
handleSoundboardPlay(event, userId);
|
||||
break;
|
||||
|
||||
case 'voice_leave':
|
||||
handleVoiceLeave(userId);
|
||||
break;
|
||||
@@ -724,12 +728,14 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string, ws: Web
|
||||
// Join room
|
||||
connectionManager.joinRoom(channelId, userId);
|
||||
|
||||
// Broadcast join
|
||||
// Broadcast join. Carries when this occupancy began so late joiners show the
|
||||
// call's real elapsed time rather than counting from their own arrival.
|
||||
connectionManager.sendToRoom(channelId, {
|
||||
type: 'voice_state_update',
|
||||
channelId,
|
||||
userId,
|
||||
action: 'join',
|
||||
startedAt: connectionManager.getRoomStartedAt(channelId) ?? undefined,
|
||||
});
|
||||
|
||||
// Also broadcast current voice status if it exists (persisted during moves)
|
||||
@@ -794,6 +800,48 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string, ws: Web
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum gap between one person's soundboard triggers.
|
||||
*
|
||||
* Enforced on the server: a client-side cooldown only slows down people who
|
||||
* are not trying to abuse it, and a soundboard is the easiest thing in a chat
|
||||
* app to turn into a weapon.
|
||||
*/
|
||||
const SOUNDBOARD_COOLDOWN_MS = 2000;
|
||||
const lastSoundboardPlay = new Map<string, number>();
|
||||
|
||||
function handleSoundboardPlay(event: Record<string, unknown>, userId: string): void {
|
||||
const soundId = event.soundId;
|
||||
if (typeof soundId !== 'string' || !soundId) return;
|
||||
|
||||
const now = Date.now();
|
||||
const last = lastSoundboardPlay.get(userId) ?? 0;
|
||||
if (now - last < SOUNDBOARD_COOLDOWN_MS) return;
|
||||
|
||||
// Must be in a voice room: a soundboard is something you press while in a
|
||||
// call, not a way to make noise in a call you are not part of.
|
||||
const userRoom = connectionManager.getUserRoom(userId);
|
||||
if (!userRoom || userRoom.room.roomType !== 'space') return;
|
||||
|
||||
const sound = getDb().select().from(schema.soundboardSounds)
|
||||
.where(eq(schema.soundboardSounds.id, soundId)).get();
|
||||
if (!sound) return;
|
||||
|
||||
// And the sound must belong to the space whose call they are in.
|
||||
const meta = userRoom.room.metadata as SpaceRoomMeta;
|
||||
if (sound.spaceId !== meta.spaceId) return;
|
||||
|
||||
lastSoundboardPlay.set(userId, now);
|
||||
|
||||
connectionManager.sendToRoomParticipants(userRoom.roomId, {
|
||||
type: 'soundboard_played',
|
||||
soundId: sound.id,
|
||||
userId,
|
||||
name: sound.name,
|
||||
filename: sound.filename,
|
||||
});
|
||||
}
|
||||
|
||||
function handleVoiceLeave(userId: string): void {
|
||||
connectionManager.clearVoiceWs(userId);
|
||||
const left = connectionManager.leaveCurrentRoom(userId);
|
||||
|
||||
Reference in New Issue
Block a user