feat: soundboard, account menu, and call timer
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
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.
This commit is contained in:
@@ -415,6 +415,7 @@ class ConnectionManager {
|
||||
type: 'space_voice_state',
|
||||
spaceId,
|
||||
voiceStates: snapshot.voiceStates,
|
||||
voiceRoomStarts: snapshot.voiceRoomStarts,
|
||||
voiceUserStates: snapshot.voiceUserStates,
|
||||
spaceVoiceStates: snapshot.spaceVoiceStates,
|
||||
});
|
||||
@@ -442,11 +443,13 @@ class ConnectionManager {
|
||||
*/
|
||||
buildSpaceVoiceState(spaceId: string, userId: string): {
|
||||
voiceStates: Record<string, string[]>;
|
||||
voiceRoomStarts: Record<string, number>;
|
||||
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
|
||||
spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
|
||||
} {
|
||||
const db = getDb();
|
||||
const voiceStates: Record<string, string[]> = {};
|
||||
const voiceRoomStarts: Record<string, number> = {};
|
||||
const voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = {};
|
||||
const spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> = {};
|
||||
|
||||
@@ -463,6 +466,8 @@ class ConnectionManager {
|
||||
if (participants.size > 0) {
|
||||
const ids = Array.from(participants);
|
||||
voiceStates[ch.id] = ids;
|
||||
const startedAt = this.getRoomStartedAt(ch.id);
|
||||
if (startedAt !== null) voiceRoomStarts[ch.id] = startedAt;
|
||||
for (const uid of ids) {
|
||||
const status = this.getVoiceUserStatus(uid);
|
||||
if (status) voiceUserStates[uid] = status;
|
||||
@@ -500,7 +505,7 @@ class ConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
return { voiceStates, voiceUserStates, spaceVoiceStates };
|
||||
return { voiceStates, voiceRoomStarts, voiceUserStates, spaceVoiceStates };
|
||||
}
|
||||
|
||||
// ─── Unified VoiceRoom API ─────────────────────────────────────────────────
|
||||
@@ -976,6 +981,30 @@ class ConnectionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When the current occupancy of a room began. Null when nobody is in it —
|
||||
* empty space rooms are destroyed, which is what makes the call timer reset
|
||||
* once the last person leaves.
|
||||
*/
|
||||
getRoomStartedAt(roomId: string): number | null {
|
||||
return this.voiceRooms.get(roomId)?.startedAt ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send only to the people actually inside a room.
|
||||
*
|
||||
* Distinct from `sendToRoom`, which fans a space room out to the whole
|
||||
* space — right for presence updates the sidebar shows, wrong for anything
|
||||
* audible: a soundboard clip must reach the call, not everyone online.
|
||||
*/
|
||||
sendToRoomParticipants(roomId: string, event: ServerEvent): void {
|
||||
const room = this.voiceRooms.get(roomId);
|
||||
if (!room) return;
|
||||
for (const userId of room.participants) {
|
||||
this.sendToUser(userId, event);
|
||||
}
|
||||
}
|
||||
|
||||
/** Send to all connections of all online users. */
|
||||
sendToAll(event: ServerEvent, excludeUserId?: string): void {
|
||||
const message = JSON.stringify(event);
|
||||
|
||||
Reference in New Issue
Block a user