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);
|
||||
|
||||
@@ -403,6 +403,7 @@ export type ClientEvent =
|
||||
| { type: 'typing_start'; channelId: string }
|
||||
| { type: 'presence_update'; status: 'online' | 'idle' | 'dnd' }
|
||||
| { type: 'voice_join'; channelId: string }
|
||||
| { type: 'soundboard_play'; soundId: string }
|
||||
| { type: 'voice_leave' }
|
||||
| { type: 'dm_message_create'; dmChannelId: string; content?: string; attachments?: string[]; replyToId?: string }
|
||||
| { type: 'dm_typing_start'; dmChannelId: string }
|
||||
@@ -426,13 +427,14 @@ export type ClientEvent =
|
||||
|
||||
// Server → Client Events
|
||||
export type ServerEvent =
|
||||
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }>; userActivities?: Record<string, Activity[]>; rejectedPeerOrigins?: string[]; awaitingApprovalPeerOrigins?: string[]; activePeerOrigins?: string[]; pendingApprovalCount?: number }
|
||||
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceRoomStarts?: Record<string, number>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }>; userActivities?: Record<string, Activity[]>; rejectedPeerOrigins?: string[]; awaitingApprovalPeerOrigins?: string[]; activePeerOrigins?: string[]; pendingApprovalCount?: number }
|
||||
| { type: 'message_created'; message: MessageWithUser }
|
||||
| { type: 'message_updated'; message: MessageWithUser }
|
||||
| { type: 'message_deleted'; messageId: string; channelId: string }
|
||||
| { type: 'typing'; channelId: string; userId: string; username: string }
|
||||
| { type: 'presence_update'; userId: string; status: string; activities?: Activity[] }
|
||||
| { type: 'voice_state_update'; channelId: string; userId: string; action: 'join' | 'leave' }
|
||||
| { type: 'voice_state_update'; channelId: string; userId: string; action: 'join' | 'leave'; startedAt?: number }
|
||||
| { type: 'soundboard_played'; soundId: string; userId: string; name: string; filename: string }
|
||||
| { type: 'member_joined'; spaceId: string; member: MemberWithUser }
|
||||
| { type: 'member_left'; spaceId: string; userId: string }
|
||||
| { type: 'dm_message_created'; message: DmMessageWithUser }
|
||||
@@ -451,7 +453,7 @@ export type ServerEvent =
|
||||
| { type: 'dm_call_ended'; dmChannelId: string }
|
||||
| { type: 'dm_call_undeliverable'; dmChannelId: string | null; federatedCallId: string; terminal: boolean; phase: DmCallPhase; failures: DmCallUndeliverableFailure[] }
|
||||
| { type: 'voice_status_update'; userId: string; channelId: string; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }
|
||||
| { type: 'space_voice_state'; spaceId: string; voiceStates: Record<string, string[]>; voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> }
|
||||
| { type: 'space_voice_state'; 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 }> }
|
||||
| { type: 'dm_channel_created'; dmChannel: DmChannel }
|
||||
| { type: 'dm_channel_closed'; dmChannelId: string }
|
||||
| { type: 'dm_channel_updated'; dmChannelId: string; name: string | null; icon: string | null }
|
||||
|
||||
@@ -75,6 +75,15 @@ import type {
|
||||
} from '@backspace/shared';
|
||||
import type { AuditEvent } from '@backspace/shared/src/audit.js';
|
||||
|
||||
export interface SoundboardSound {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
uploaderId: string | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface StatsLeader {
|
||||
userId: string;
|
||||
username: string;
|
||||
@@ -302,6 +311,12 @@ export class BackspaceApiClient {
|
||||
removeFavorite: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
readonly soundboard: {
|
||||
list: (spaceId: string) => Promise<{ sounds: SoundboardSound[] }>;
|
||||
add: (spaceId: string, name: string, filename: string) => Promise<SoundboardSound>;
|
||||
remove: (soundId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
readonly stats: {
|
||||
space: (spaceId: string, days: number) => Promise<SpaceStats>;
|
||||
};
|
||||
@@ -724,6 +739,13 @@ export class BackspaceApiClient {
|
||||
},
|
||||
};
|
||||
|
||||
this.soundboard = {
|
||||
list: (spaceId: string) => request<{ sounds: SoundboardSound[] }>('GET', `/spaces/${spaceId}/sounds`),
|
||||
add: (spaceId: string, name: string, filename: string) =>
|
||||
request<SoundboardSound>('POST', `/spaces/${spaceId}/sounds`, { name, filename }),
|
||||
remove: (soundId: string) => request<void>('DELETE', `/sounds/${soundId}`),
|
||||
};
|
||||
|
||||
this.stats = {
|
||||
space: (spaceId: string, days: number) =>
|
||||
request<SpaceStats>('GET', `/spaces/${spaceId}/stats?days=${days}`),
|
||||
|
||||
@@ -185,6 +185,34 @@ export class AudioManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a sound from an arbitrary URL (soundboard clips, which live in the
|
||||
* upload directory rather than /sounds). Cached by URL like the built-in
|
||||
* effects, so repeats do not re-download.
|
||||
*/
|
||||
async playUrl(url: string, options: { volume?: number } = {}): Promise<void> {
|
||||
try {
|
||||
const ctx = this.ensureContext();
|
||||
await this.resumeContext();
|
||||
let buffer = this.soundBuffers.get(url);
|
||||
if (!buffer) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return;
|
||||
buffer = await ctx.decodeAudioData(await response.arrayBuffer());
|
||||
this.soundBuffers.set(url, buffer);
|
||||
}
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.value = options.volume ?? 1;
|
||||
source.connect(gain);
|
||||
gain.connect(this.getMasterOutput());
|
||||
source.start(0);
|
||||
} catch (err) {
|
||||
console.warn('[AudioManager] playUrl failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
async playSound(name: string, options: { loop?: boolean; volume?: number } = {}): Promise<AudioBufferSourceNode | null> {
|
||||
await this.resumeContext();
|
||||
const buffer = await this.loadSound(name);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { UserStatus } from '@backspace/shared';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { useT, type TranslationKey } from '../../i18n';
|
||||
|
||||
interface AccountMenuProps {
|
||||
onClose: () => void;
|
||||
onEditProfile: () => void;
|
||||
}
|
||||
|
||||
const STATUSES: { value: UserStatus; key: TranslationKey; dot: string }[] = [
|
||||
{ value: 'online', key: 'accountMenu.status.online', dot: 'bg-status-online' },
|
||||
{ value: 'idle', key: 'accountMenu.status.idle', dot: 'bg-status-idle' },
|
||||
{ value: 'dnd', key: 'accountMenu.status.dnd', dot: 'bg-status-dnd' },
|
||||
// 'offline' chosen deliberately is what other clients call invisible.
|
||||
{ value: 'offline', key: 'accountMenu.status.offline', dot: 'bg-txt-tertiary' },
|
||||
];
|
||||
|
||||
export function AccountMenu({ onClose, onEditProfile }: AccountMenuProps) {
|
||||
const t = useT();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const updateProfile = useAuthStore((s) => s.updateProfile);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointer = (e: MouseEvent | TouchEvent) => {
|
||||
if (!menuRef.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
|
||||
};
|
||||
// touchstart alongside mousedown: iOS Safari does not reliably synthesise
|
||||
// mousedown from a tap, matching what the other popovers here do.
|
||||
document.addEventListener('mousedown', handlePointer);
|
||||
document.addEventListener('touchstart', handlePointer);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointer);
|
||||
document.removeEventListener('touchstart', handlePointer);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const handleStatus = async (status: UserStatus) => {
|
||||
if (status === (user.status ?? 'online')) return onClose();
|
||||
try {
|
||||
await updateProfile({ status });
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyId = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(user.id);
|
||||
setCopied(true);
|
||||
// Left open on purpose: the confirmation is the only feedback, and
|
||||
// closing immediately would hide it.
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard is unavailable over plain http or without permission.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
className="absolute bottom-full left-2 right-2 mb-2 z-[200] glass rounded-xl overflow-hidden py-1.5 shadow-xl"
|
||||
>
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => { onEditProfile(); onClose(); }}
|
||||
className="w-full px-3 py-2 flex items-center gap-2.5 text-[13.5px] text-txt-secondary hover:bg-interactive-hover hover:text-txt-primary transition-colors"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25ZM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83Z" />
|
||||
</svg>
|
||||
{t('accountMenu.editProfile')}
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-white/[0.06] my-1.5 mx-2" />
|
||||
<div className="px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-txt-tertiary">
|
||||
{t('accountMenu.status')}
|
||||
</div>
|
||||
{STATUSES.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
role="menuitemradio"
|
||||
aria-checked={(user.status ?? 'online') === option.value}
|
||||
onClick={() => void handleStatus(option.value)}
|
||||
className="w-full px-3 py-1.5 flex items-center gap-2.5 text-[13.5px] text-txt-secondary hover:bg-interactive-hover hover:text-txt-primary transition-colors"
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${option.dot}`} />
|
||||
<span className="flex-1 text-left">{t(option.key)}</span>
|
||||
{(user.status ?? 'online') === option.value && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="h-px bg-white/[0.06] my-1.5 mx-2" />
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => void handleCopyId()}
|
||||
className="w-full px-3 py-2 flex items-center gap-2.5 text-[13.5px] text-txt-secondary hover:bg-interactive-hover hover:text-txt-primary transition-colors"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M16 1H4a2 2 0 0 0-2 2v14h2V3h12V1Zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2Zm0 16H8V7h11v14Z" />
|
||||
</svg>
|
||||
{copied ? t('accountMenu.copied') : t('accountMenu.copyId')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useUIStore } from '../../stores/uiStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { useInstanceStore } from '../../stores/instanceStore';
|
||||
import { VoiceChannel } from '../voice/VoiceChannel';
|
||||
import { AccountMenu } from './AccountMenu';
|
||||
import { VoiceControls } from '../voice/VoiceControls';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { ProfileAvatar } from '../ui/ProfileAvatar';
|
||||
@@ -844,6 +845,7 @@ function UserAreaPanel({
|
||||
onDeafenToggle: () => void;
|
||||
onSettingsClick: (tab?: string) => void;
|
||||
}) {
|
||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
||||
const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null);
|
||||
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
|
||||
const outputDeviceId = useVoiceStore((s) => s.outputDeviceId);
|
||||
@@ -1132,14 +1134,27 @@ function UserAreaPanel({
|
||||
)}
|
||||
|
||||
{/* User area bar */}
|
||||
<div className="h-[52px] px-2 flex items-center select-none">
|
||||
{/* Avatar + name */}
|
||||
<div className="p-1 hover:bg-interactive-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group">
|
||||
<div className="relative h-[52px] px-2 flex items-center select-none">
|
||||
{accountMenuOpen && (
|
||||
<AccountMenu
|
||||
onClose={() => setAccountMenuOpen(false)}
|
||||
onEditProfile={() => onSettingsClick('account')}
|
||||
/>
|
||||
)}
|
||||
{/* Avatar + name. The avatar opens the profile card (ProfileAvatar);
|
||||
the name opens the account menu — which is what the cursor here has
|
||||
been promising all along without anything happening. */}
|
||||
<div className="p-1 hover:bg-interactive-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 transition-colors group">
|
||||
<ProfileAvatar src={user.avatar} name={user.displayName ?? user.username} size={34} status={user.status} user={user} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<button
|
||||
onClick={() => setAccountMenuOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={accountMenuOpen}
|
||||
className="flex-1 min-w-0 text-left cursor-pointer"
|
||||
>
|
||||
<div className="text-[13.5px] font-semibold text-txt-primary truncate leading-tight">{user.displayName ?? user.username}</div>
|
||||
<div className="text-[11px] text-txt-tertiary truncate leading-tight group-hover:text-txt-secondary">@{user.username}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface CallTimerProps {
|
||||
startedAt: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function format(elapsedMs: number): string {
|
||||
const total = Math.max(0, Math.floor(elapsedMs / 1000));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the current call has been running.
|
||||
*
|
||||
* `startedAt` comes from the server, so everyone sees the same figure and a
|
||||
* late joiner sees the call's age rather than their own. The server destroys an
|
||||
* empty room, so the next call starts from zero on its own.
|
||||
*/
|
||||
export function CallTimer({ startedAt, className = '' }: CallTimerProps) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
// Aligned to the next whole second so the digits do not visibly stutter.
|
||||
const timeout = setTimeout(() => setNow(Date.now()), 1000 - (Date.now() % 1000));
|
||||
return () => clearTimeout(timeout);
|
||||
}, [now]);
|
||||
|
||||
return (
|
||||
<span className={`tabular-nums ${className}`} title={new Date(startedAt).toLocaleTimeString()}>
|
||||
{format(now - startedAt)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api, type SoundboardSound } from '../../api/client';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { useTransferStore } from '../../stores/transferStore';
|
||||
import { waitForTransferAttachment } from '../../utils/waitForTransfer';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
interface SoundboardPopoverProps {
|
||||
spaceId: string;
|
||||
canManage: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Clips are short gags; anything larger is a music file in disguise. */
|
||||
const MAX_SOUND_BYTES = 1024 * 1024;
|
||||
|
||||
export function SoundboardPopover({ spaceId, canManage, onClose }: SoundboardPopoverProps) {
|
||||
const t = useT();
|
||||
const [sounds, setSounds] = useState<SoundboardSound[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.soundboard.list(spaceId)
|
||||
.then(({ sounds: list }) => { if (!cancelled) setSounds(list); })
|
||||
.catch(() => { /* an empty board is the honest fallback */ });
|
||||
return () => { cancelled = true; };
|
||||
}, [spaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointer = (e: MouseEvent | TouchEvent) => {
|
||||
if (!panelRef.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('mousedown', handlePointer);
|
||||
document.addEventListener('touchstart', handlePointer);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointer);
|
||||
document.removeEventListener('touchstart', handlePointer);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// Fire and forget: the server echoes the clip back to everyone in the call,
|
||||
// this client included, so the presser hears exactly what the others hear —
|
||||
// including the server's refusal when the cooldown is still running.
|
||||
const play = (soundId: string) => wsSend({ type: 'soundboard_play', soundId });
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
setError('');
|
||||
if (file.size > MAX_SOUND_BYTES) {
|
||||
setError(t('soundboard.tooLarge'));
|
||||
return;
|
||||
}
|
||||
const name = window.prompt(t('soundboard.namePrompt'), file.name.replace(/\.[^.]+$/, ''));
|
||||
if (!name) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const tid = await useTransferStore.getState().startUpload(file, { tray: false });
|
||||
const { filename } = await waitForTransferAttachment(tid);
|
||||
const created = await api.soundboard.add(spaceId, name, filename);
|
||||
setSounds((prev) => [...prev, created]);
|
||||
} catch {
|
||||
setError(t('soundboard.tooLarge'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (soundId: string) => {
|
||||
const previous = sounds;
|
||||
setSounds((prev) => prev.filter((s) => s.id !== soundId));
|
||||
try {
|
||||
await api.soundboard.remove(soundId);
|
||||
} catch {
|
||||
setSounds(previous);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute bottom-full left-2 right-2 mb-2 z-[200] glass rounded-xl overflow-hidden p-3 shadow-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[12px] font-semibold uppercase tracking-wider text-txt-tertiary">
|
||||
{t('soundboard.title')}
|
||||
</span>
|
||||
{canManage && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="text-[11px] text-accent-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
{uploading ? t('soundboard.adding') : t('soundboard.add')}
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void handleFile(file);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-[11px] text-txt-danger mb-2">{error}</div>}
|
||||
|
||||
{sounds.length === 0 ? (
|
||||
<p className="text-[12px] text-txt-tertiary py-2">{t('soundboard.empty')}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-1.5 max-h-[220px] overflow-y-auto scrollbar-thin">
|
||||
{sounds.map((sound) => (
|
||||
<div key={sound.id} className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => play(sound.id)}
|
||||
className="w-full px-2 py-2.5 rounded-lg bg-surface-elevated text-txt-secondary hover:text-txt-primary hover:brightness-125 transition-all text-[11px] font-medium truncate"
|
||||
title={sound.name}
|
||||
>
|
||||
{sound.name}
|
||||
</button>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRemove(sound.id)}
|
||||
title={t('soundboard.remove')}
|
||||
aria-label={t('soundboard.remove')}
|
||||
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-accent-rose text-white text-[10px] leading-none opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { CallTimer } from './CallTimer';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
|
||||
@@ -33,6 +34,9 @@ interface VoiceChannelProps {
|
||||
|
||||
/** Wrapper component for the volume slider so it can use hooks (useState). */
|
||||
export function VoiceChannel({ channelId, channelName, onClick, locked, canManage, onSettingsClick, voiceUserHandlers, dropZone }: VoiceChannelProps) {
|
||||
// Present only while someone is in the channel; the server drops the room
|
||||
// when it empties, which is what makes the next call start from zero.
|
||||
const callStartedAt = useVoiceStore((s) => s.voiceRoomStarts.get(channelId));
|
||||
const serverVoiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
|
||||
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
const participants = useVoiceStore((s) => s.participants);
|
||||
@@ -141,6 +145,12 @@ export function VoiceChannel({ channelId, channelName, onClick, locked, canManag
|
||||
</svg>
|
||||
)}
|
||||
<span className="truncate text-[15px] font-medium flex-1 text-left">{channelName}</span>
|
||||
{callStartedAt !== undefined && (
|
||||
<CallTimer
|
||||
startedAt={callStartedAt}
|
||||
className="flex-shrink-0 text-[11px] text-txt-tertiary font-medium"
|
||||
/>
|
||||
)}
|
||||
{canManage && (
|
||||
<svg
|
||||
width="16"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
||||
import { ConnectionInfoPopover } from './ConnectionInfoPopover';
|
||||
import { SoundboardPopover } from './SoundboardPopover';
|
||||
import { startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import { broadcastVoiceStatus } from '../../utils/voice';
|
||||
@@ -22,6 +23,7 @@ export function VoiceControls() {
|
||||
const currentVoiceChannelName = useVoiceStore((s) => s.currentVoiceChannelName);
|
||||
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
||||
const navigate = useNavigate();
|
||||
const [showSoundboard, setShowSoundboard] = useState(false);
|
||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
||||
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
|
||||
@@ -119,6 +121,19 @@ export function VoiceControls() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Zero-height anchor: the component returns a fragment, so without a
|
||||
positioned ancestor the popover would resolve against whatever
|
||||
happened to be relative further up the sidebar. */}
|
||||
<div className="relative">
|
||||
{showSoundboard && currentVoiceSpaceId && (
|
||||
<SoundboardPopover
|
||||
spaceId={currentVoiceSpaceId}
|
||||
canManage={hasPermissionBit(channelPerms, PermissionBits.MANAGE_SPACE)}
|
||||
onClose={() => setShowSoundboard(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 1: Signal icon + status text + disconnect */}
|
||||
<div className="relative flex items-center gap-2 px-3 pt-3 pb-1">
|
||||
<button
|
||||
@@ -216,6 +231,23 @@ export function VoiceControls() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Soundboard — space calls only: clips belong to a space. */}
|
||||
{currentVoiceSpaceId && (
|
||||
<button
|
||||
onClick={() => setShowSoundboard((v) => !v)}
|
||||
className={`${btnBase} ${
|
||||
showSoundboard
|
||||
? 'bg-surface-base text-accent-primary hover:bg-surface-channel'
|
||||
: btnDefaultStyle
|
||||
}`}
|
||||
title="Soundboard"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6Z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Video Quality */}
|
||||
<button
|
||||
ref={qualityBtnRef}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -33,6 +33,26 @@ export const en = {
|
||||
'settings.voice.micTest.idle': 'Test your mic without joining a call.',
|
||||
'settings.voice.micTest.failed': 'Could not open the microphone. Check the device and its permission.',
|
||||
|
||||
// Soundboard
|
||||
'soundboard.title': 'Soundboard',
|
||||
'soundboard.empty': 'No sounds yet.',
|
||||
'soundboard.add': 'Add sound',
|
||||
'soundboard.adding': 'Uploading...',
|
||||
'soundboard.remove': 'Remove',
|
||||
'soundboard.namePrompt': 'Name for this sound',
|
||||
'soundboard.joinFirst': 'Join a voice channel to use the soundboard.',
|
||||
'soundboard.tooLarge': 'Sound must be under 1 MB and a few seconds long.',
|
||||
|
||||
// Account menu (own name in the user bar)
|
||||
'accountMenu.editProfile': 'Edit Profile',
|
||||
'accountMenu.status': 'Status',
|
||||
'accountMenu.status.online': 'Online',
|
||||
'accountMenu.status.idle': 'Idle',
|
||||
'accountMenu.status.dnd': 'Do Not Disturb',
|
||||
'accountMenu.status.offline': 'Invisible',
|
||||
'accountMenu.copyId': 'Copy User ID',
|
||||
'accountMenu.copied': 'Copied',
|
||||
|
||||
// Statistics
|
||||
'stats.title': 'Statistics',
|
||||
'stats.range.7': 'Last 7 days',
|
||||
|
||||
@@ -32,6 +32,26 @@ export const ptBR: Partial<Dictionary> = {
|
||||
'settings.voice.micTest.idle': 'Teste seu microfone sem entrar numa call.',
|
||||
'settings.voice.micTest.failed': 'Não foi possível abrir o microfone. Verifique o dispositivo e a permissão.',
|
||||
|
||||
// Soundboard
|
||||
'soundboard.title': 'Soundboard',
|
||||
'soundboard.empty': 'Nenhum som ainda.',
|
||||
'soundboard.add': 'Adicionar som',
|
||||
'soundboard.adding': 'Enviando...',
|
||||
'soundboard.remove': 'Remover',
|
||||
'soundboard.namePrompt': 'Nome deste som',
|
||||
'soundboard.joinFirst': 'Entre num canal de voz para usar o soundboard.',
|
||||
'soundboard.tooLarge': 'O som precisa ter menos de 1 MB e poucos segundos.',
|
||||
|
||||
// Menu da conta (próprio nome na barra de usuário)
|
||||
'accountMenu.editProfile': 'Editar perfil',
|
||||
'accountMenu.status': 'Status',
|
||||
'accountMenu.status.online': 'Disponível',
|
||||
'accountMenu.status.idle': 'Ausente',
|
||||
'accountMenu.status.dnd': 'Não perturbe',
|
||||
'accountMenu.status.offline': 'Invisível',
|
||||
'accountMenu.copyId': 'Copiar ID do usuário',
|
||||
'accountMenu.copied': 'Copiado',
|
||||
|
||||
// Estatísticas
|
||||
'stats.title': 'Estatísticas',
|
||||
'stats.range.7': 'Últimos 7 dias',
|
||||
|
||||
@@ -16,6 +16,12 @@ export interface ScreenShareConfig {
|
||||
|
||||
interface VoiceState {
|
||||
voiceUsers: Map<string, string[]>; // channelId → userIds
|
||||
/**
|
||||
* When each occupied voice channel's current call began, from the server.
|
||||
* The client cannot derive this: someone joining an hour in must see the
|
||||
* call's elapsed time, not their own.
|
||||
*/
|
||||
voiceRoomStarts: Map<string, number>;
|
||||
currentVoiceChannelId: string | null;
|
||||
/**
|
||||
* Space and name of the channel the call is in, captured at join time.
|
||||
@@ -94,6 +100,8 @@ interface VoiceState {
|
||||
setVoiceUsers: (channelId: string, userIds: string[]) => void;
|
||||
addVoiceUser: (channelId: string, userId: string) => void;
|
||||
removeVoiceUser: (channelId: string, userId: string) => void;
|
||||
setVoiceRoomStart: (channelId: string, startedAt: number) => void;
|
||||
clearVoiceRoomStart: (channelId: string) => void;
|
||||
setCurrentVoiceChannel: (channelId: string | null, spaceId?: string | null, channelName?: string | null) => void;
|
||||
setParticipants: (participants: ParticipantInfo[]) => void;
|
||||
setSpeakingParticipants: (ids: Set<string>) => void;
|
||||
@@ -166,6 +174,7 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
voiceUsers: new Map(),
|
||||
voiceRoomStarts: new Map(),
|
||||
currentVoiceChannelId: null,
|
||||
currentVoiceSpaceId: null,
|
||||
currentVoiceChannelName: null,
|
||||
@@ -360,6 +369,22 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
});
|
||||
},
|
||||
|
||||
setVoiceRoomStart: (channelId, startedAt) => set((state) => {
|
||||
// First value wins: re-broadcasts on later joins carry the same start,
|
||||
// but a stray newer one must not restart a running clock.
|
||||
if (state.voiceRoomStarts.get(channelId) === startedAt) return {};
|
||||
const next = new Map(state.voiceRoomStarts);
|
||||
next.set(channelId, startedAt);
|
||||
return { voiceRoomStarts: next };
|
||||
}),
|
||||
|
||||
clearVoiceRoomStart: (channelId) => set((state) => {
|
||||
if (!state.voiceRoomStarts.has(channelId)) return {};
|
||||
const next = new Map(state.voiceRoomStarts);
|
||||
next.delete(channelId);
|
||||
return { voiceRoomStarts: next };
|
||||
}),
|
||||
|
||||
setCurrentVoiceChannel: (channelId, spaceId = null, channelName = null) => set({
|
||||
currentVoiceChannelId: channelId,
|
||||
currentVoiceSpaceId: channelId ? spaceId : null,
|
||||
@@ -527,6 +552,7 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
// Connection state
|
||||
hwOverdrive: false,
|
||||
voiceUsers: new Map(),
|
||||
voiceRoomStarts: new Map(),
|
||||
voiceUserStates: new Map(),
|
||||
currentVoiceChannelId: null,
|
||||
currentVoiceSpaceId: null,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useVoiceStore } from '../stores/voiceStore';
|
||||
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 }>;
|
||||
}
|
||||
@@ -33,6 +34,12 @@ export function applySpaceVoiceState(snapshot: SpaceVoiceStateSnapshot): void {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user