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.
71 lines
3.2 KiB
TypeScript
71 lines
3.2 KiB
TypeScript
import { useVoiceStore } from '../stores/voiceStore';
|
|
|
|
/**
|
|
* Snapshot of a single space's voice presence, delivered by the server's
|
|
* `space_voice_state` WebSocket event when the user joins a space mid-session.
|
|
* Mirrors the per-space slice of the `ready` payload (see server
|
|
* `ConnectionManager.buildSpaceVoiceState`).
|
|
*/
|
|
export interface SpaceVoiceStateSnapshot {
|
|
spaceId: 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 }>;
|
|
}
|
|
|
|
/**
|
|
* Apply a `space_voice_state` snapshot to the voice store.
|
|
*
|
|
* Scoped strictly to `snapshot.spaceId`: voice-channel occupants and per-user
|
|
* statuses are merged in (channel IDs are globally unique, so this never
|
|
* collides with other spaces), and the space-level restriction sets
|
|
* (`spaceMuted`/`spaceDeafened`/`permissionMuted`) are rebuilt for THIS space
|
|
* only — keys for other spaces are left untouched. This makes the apply
|
|
* idempotent and authoritative for the joined space without disturbing live
|
|
* voice state elsewhere (e.g. a channel the user is actively sitting in).
|
|
*
|
|
* The `ready` handler bootstraps the same data per-origin at connect time; this
|
|
* is the mid-session join counterpart and deliberately does NOT clear by origin.
|
|
*/
|
|
export function applySpaceVoiceState(snapshot: SpaceVoiceStateSnapshot): void {
|
|
const { setVoiceUsers, setVoiceUserStatus } = useVoiceStore.getState();
|
|
|
|
for (const [channelId, userIds] of Object.entries(snapshot.voiceStates)) {
|
|
setVoiceUsers(channelId, userIds);
|
|
}
|
|
if (snapshot.voiceRoomStarts) {
|
|
const { setVoiceRoomStart } = useVoiceStore.getState();
|
|
for (const [channelId, startedAt] of Object.entries(snapshot.voiceRoomStarts)) {
|
|
setVoiceRoomStart(channelId, startedAt);
|
|
}
|
|
}
|
|
for (const [userId, status] of Object.entries(snapshot.voiceUserStates)) {
|
|
setVoiceUserStatus(userId, status.isMuted, status.isDeafened, status.isCameraOn, status.isScreenSharing);
|
|
}
|
|
|
|
const vs = useVoiceStore.getState();
|
|
const nextSpaceMuted = new Set(vs.spaceMutedUserIds);
|
|
const nextSpaceDeafened = new Set(vs.spaceDeafenedUserIds);
|
|
const nextPermissionMuted = new Set(vs.permissionMutedUserIds);
|
|
|
|
// Restriction Sets are keyed `spaceId:userId`. Drop this space's existing keys
|
|
// so a re-sync is authoritative, then re-add from the snapshot.
|
|
const prefix = `${snapshot.spaceId}:`;
|
|
for (const key of [...nextSpaceMuted]) if (key.startsWith(prefix)) nextSpaceMuted.delete(key);
|
|
for (const key of [...nextSpaceDeafened]) if (key.startsWith(prefix)) nextSpaceDeafened.delete(key);
|
|
for (const key of [...nextPermissionMuted]) if (key.startsWith(prefix)) nextPermissionMuted.delete(key);
|
|
|
|
for (const [key, state] of Object.entries(snapshot.spaceVoiceStates)) {
|
|
if (state.spaceMuted) nextSpaceMuted.add(key);
|
|
if (state.spaceDeafened) nextSpaceDeafened.add(key);
|
|
if (state.permissionMuted) nextPermissionMuted.add(key);
|
|
}
|
|
|
|
useVoiceStore.setState({
|
|
spaceMutedUserIds: nextSpaceMuted,
|
|
spaceDeafenedUserIds: nextSpaceDeafened,
|
|
permissionMutedUserIds: nextPermissionMuted,
|
|
});
|
|
}
|