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:
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE `soundboard_sounds` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`space_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`filename` text NOT NULL,
|
||||
`uploader_id` text,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_soundboard_space` ON `soundboard_sounds` (`space_id`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,13 @@
|
||||
"when": 1788193659704,
|
||||
"tag": "0014_mean_killer_shrike",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "6",
|
||||
"when": 1788194631751,
|
||||
"tag": "0015_young_human_fly",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -633,3 +633,15 @@ export const voiceSessions = sqliteTable('voice_sessions', {
|
||||
userIdx: index('idx_voice_sessions_user').on(table.userId),
|
||||
openIdx: index('idx_voice_sessions_ended').on(table.endedAt),
|
||||
}));
|
||||
|
||||
/** Soundboard clips, per space. The file lives in the normal upload dir. */
|
||||
export const soundboardSounds = sqliteTable('soundboard_sounds', {
|
||||
id: text('id').primaryKey(),
|
||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
filename: text('filename').notNull(),
|
||||
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
spaceIdx: index('idx_soundboard_space').on(table.spaceId),
|
||||
}));
|
||||
|
||||
@@ -18,6 +18,7 @@ import { livekitRoutes } from './routes/livekit.js';
|
||||
import { spotifyRoutes } from './routes/spotify.js';
|
||||
import { auditRoutes } from './routes/audit.js';
|
||||
import { statsRoutes } from './routes/stats.js';
|
||||
import { soundboardRoutes } from './routes/soundboard.js';
|
||||
import { closeOrphanedVoiceSessions } from './utils/voiceSessions.js';
|
||||
import { socialRoutes } from './routes/social.js';
|
||||
import { settingsRoutes } from './routes/settings.js';
|
||||
@@ -137,6 +138,7 @@ async function main(): Promise<void> {
|
||||
await app.register(spotifyRoutes);
|
||||
await app.register(auditRoutes);
|
||||
await app.register(statsRoutes);
|
||||
await app.register(soundboardRoutes);
|
||||
await app.register(socialRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(utilRoutes);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { hasPermission, isMember } from '../utils/permissions.js';
|
||||
import { PermissionBits } from '@backspace/shared/src/permissions.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
|
||||
/** A soundboard is a shortlist of gags, not a media library. */
|
||||
const MAX_SOUNDS_PER_SPACE = 48;
|
||||
const MAX_NAME_LENGTH = 32;
|
||||
|
||||
export async function soundboardRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/api/spaces/:id/sounds',
|
||||
{ preHandler: authenticate },
|
||||
async (request, reply) => {
|
||||
if (!isMember(request.params.id, request.userId)) {
|
||||
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
|
||||
}
|
||||
const rows = getDb().select().from(schema.soundboardSounds)
|
||||
.where(eq(schema.soundboardSounds.spaceId, request.params.id)).all();
|
||||
return reply.code(200).send({ sounds: rows });
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
|
||||
'/api/spaces/:id/sounds',
|
||||
{ preHandler: authenticate },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
// Adding is gated but playing is not: anyone in the call may press a
|
||||
// button, only the people who run the space decide what the buttons are.
|
||||
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
|
||||
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
const name = (request.body?.name ?? '').trim().slice(0, MAX_NAME_LENGTH);
|
||||
const filename = (request.body?.filename ?? '').trim();
|
||||
if (!name || !filename) {
|
||||
return reply.code(400).send({ error: 'name and filename are required', statusCode: 400 });
|
||||
}
|
||||
// The filename is a key into the upload directory, never a path.
|
||||
if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
|
||||
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const count = db.select().from(schema.soundboardSounds)
|
||||
.where(eq(schema.soundboardSounds.spaceId, id)).all().length;
|
||||
if (count >= MAX_SOUNDS_PER_SPACE) {
|
||||
return reply.code(409).send({ error: `At most ${MAX_SOUNDS_PER_SPACE} sounds`, statusCode: 409 });
|
||||
}
|
||||
|
||||
const row = {
|
||||
id: generateSnowflake(),
|
||||
spaceId: id,
|
||||
name,
|
||||
filename,
|
||||
uploaderId: request.userId,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
db.insert(schema.soundboardSounds).values(row).run();
|
||||
return reply.code(201).send(row);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/sounds/:id',
|
||||
{ preHandler: authenticate },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
const sound = db.select().from(schema.soundboardSounds)
|
||||
.where(eq(schema.soundboardSounds.id, request.params.id)).get();
|
||||
if (!sound) return reply.code(404).send({ error: 'Sound not found', statusCode: 404 });
|
||||
|
||||
if (!hasPermission(request.userId, sound.spaceId, PermissionBits.MANAGE_SPACE)) {
|
||||
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
db.delete(schema.soundboardSounds).where(eq(schema.soundboardSounds.id, request.params.id)).run();
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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