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

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:
2026-08-31 13:45:18 -03:00
parent ef5545465d
commit f5451e1b14
22 changed files with 5026 additions and 11 deletions
@@ -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, "when": 1788193659704,
"tag": "0014_mean_killer_shrike", "tag": "0014_mean_killer_shrike",
"breakpoints": true "breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1788194631751,
"tag": "0015_young_human_fly",
"breakpoints": true
} }
] ]
} }
+12
View File
@@ -633,3 +633,15 @@ export const voiceSessions = sqliteTable('voice_sessions', {
userIdx: index('idx_voice_sessions_user').on(table.userId), userIdx: index('idx_voice_sessions_user').on(table.userId),
openIdx: index('idx_voice_sessions_ended').on(table.endedAt), 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),
}));
+2
View File
@@ -18,6 +18,7 @@ import { livekitRoutes } from './routes/livekit.js';
import { spotifyRoutes } from './routes/spotify.js'; import { spotifyRoutes } from './routes/spotify.js';
import { auditRoutes } from './routes/audit.js'; import { auditRoutes } from './routes/audit.js';
import { statsRoutes } from './routes/stats.js'; import { statsRoutes } from './routes/stats.js';
import { soundboardRoutes } from './routes/soundboard.js';
import { closeOrphanedVoiceSessions } from './utils/voiceSessions.js'; import { closeOrphanedVoiceSessions } from './utils/voiceSessions.js';
import { socialRoutes } from './routes/social.js'; import { socialRoutes } from './routes/social.js';
import { settingsRoutes } from './routes/settings.js'; import { settingsRoutes } from './routes/settings.js';
@@ -137,6 +138,7 @@ async function main(): Promise<void> {
await app.register(spotifyRoutes); await app.register(spotifyRoutes);
await app.register(auditRoutes); await app.register(auditRoutes);
await app.register(statsRoutes); await app.register(statsRoutes);
await app.register(soundboardRoutes);
await app.register(socialRoutes); await app.register(socialRoutes);
await app.register(settingsRoutes); await app.register(settingsRoutes);
await app.register(utilRoutes); await app.register(utilRoutes);
+85
View File
@@ -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();
},
);
}
+49 -1
View File
@@ -168,6 +168,10 @@ export function handleClientEvent(
case 'voice_join': case 'voice_join':
handleVoiceJoin(event, userId, ws); handleVoiceJoin(event, userId, ws);
break; break;
case 'soundboard_play':
handleSoundboardPlay(event, userId);
break;
case 'voice_leave': case 'voice_leave':
handleVoiceLeave(userId); handleVoiceLeave(userId);
break; break;
@@ -724,12 +728,14 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string, ws: Web
// Join room // Join room
connectionManager.joinRoom(channelId, userId); 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, { connectionManager.sendToRoom(channelId, {
type: 'voice_state_update', type: 'voice_state_update',
channelId, channelId,
userId, userId,
action: 'join', action: 'join',
startedAt: connectionManager.getRoomStartedAt(channelId) ?? undefined,
}); });
// Also broadcast current voice status if it exists (persisted during moves) // 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 { function handleVoiceLeave(userId: string): void {
connectionManager.clearVoiceWs(userId); connectionManager.clearVoiceWs(userId);
const left = connectionManager.leaveCurrentRoom(userId); const left = connectionManager.leaveCurrentRoom(userId);
+30 -1
View File
@@ -415,6 +415,7 @@ class ConnectionManager {
type: 'space_voice_state', type: 'space_voice_state',
spaceId, spaceId,
voiceStates: snapshot.voiceStates, voiceStates: snapshot.voiceStates,
voiceRoomStarts: snapshot.voiceRoomStarts,
voiceUserStates: snapshot.voiceUserStates, voiceUserStates: snapshot.voiceUserStates,
spaceVoiceStates: snapshot.spaceVoiceStates, spaceVoiceStates: snapshot.spaceVoiceStates,
}); });
@@ -442,11 +443,13 @@ class ConnectionManager {
*/ */
buildSpaceVoiceState(spaceId: string, userId: string): { buildSpaceVoiceState(spaceId: string, userId: string): {
voiceStates: Record<string, string[]>; voiceStates: Record<string, string[]>;
voiceRoomStarts: Record<string, number>;
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>; spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
} { } {
const db = getDb(); const db = getDb();
const voiceStates: Record<string, string[]> = {}; const voiceStates: Record<string, string[]> = {};
const voiceRoomStarts: Record<string, number> = {};
const voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = {}; const voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = {};
const spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> = {}; const spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> = {};
@@ -463,6 +466,8 @@ class ConnectionManager {
if (participants.size > 0) { if (participants.size > 0) {
const ids = Array.from(participants); const ids = Array.from(participants);
voiceStates[ch.id] = ids; voiceStates[ch.id] = ids;
const startedAt = this.getRoomStartedAt(ch.id);
if (startedAt !== null) voiceRoomStarts[ch.id] = startedAt;
for (const uid of ids) { for (const uid of ids) {
const status = this.getVoiceUserStatus(uid); const status = this.getVoiceUserStatus(uid);
if (status) voiceUserStates[uid] = status; if (status) voiceUserStates[uid] = status;
@@ -500,7 +505,7 @@ class ConnectionManager {
} }
} }
return { voiceStates, voiceUserStates, spaceVoiceStates }; return { voiceStates, voiceRoomStarts, voiceUserStates, spaceVoiceStates };
} }
// ─── Unified VoiceRoom API ───────────────────────────────────────────────── // ─── 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. */ /** Send to all connections of all online users. */
sendToAll(event: ServerEvent, excludeUserId?: string): void { sendToAll(event: ServerEvent, excludeUserId?: string): void {
const message = JSON.stringify(event); const message = JSON.stringify(event);
+5 -3
View File
@@ -403,6 +403,7 @@ export type ClientEvent =
| { type: 'typing_start'; channelId: string } | { type: 'typing_start'; channelId: string }
| { type: 'presence_update'; status: 'online' | 'idle' | 'dnd' } | { type: 'presence_update'; status: 'online' | 'idle' | 'dnd' }
| { type: 'voice_join'; channelId: string } | { type: 'voice_join'; channelId: string }
| { type: 'soundboard_play'; soundId: string }
| { type: 'voice_leave' } | { type: 'voice_leave' }
| { type: 'dm_message_create'; dmChannelId: string; content?: string; attachments?: string[]; replyToId?: string } | { type: 'dm_message_create'; dmChannelId: string; content?: string; attachments?: string[]; replyToId?: string }
| { type: 'dm_typing_start'; dmChannelId: string } | { type: 'dm_typing_start'; dmChannelId: string }
@@ -426,13 +427,14 @@ export type ClientEvent =
// Server → Client Events // Server → Client Events
export type ServerEvent = 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_created'; message: MessageWithUser }
| { type: 'message_updated'; message: MessageWithUser } | { type: 'message_updated'; message: MessageWithUser }
| { type: 'message_deleted'; messageId: string; channelId: string } | { type: 'message_deleted'; messageId: string; channelId: string }
| { type: 'typing'; channelId: string; userId: string; username: string } | { type: 'typing'; channelId: string; userId: string; username: string }
| { type: 'presence_update'; userId: string; status: string; activities?: Activity[] } | { 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_joined'; spaceId: string; member: MemberWithUser }
| { type: 'member_left'; spaceId: string; userId: string } | { type: 'member_left'; spaceId: string; userId: string }
| { type: 'dm_message_created'; message: DmMessageWithUser } | { type: 'dm_message_created'; message: DmMessageWithUser }
@@ -451,7 +453,7 @@ export type ServerEvent =
| { type: 'dm_call_ended'; dmChannelId: string } | { type: 'dm_call_ended'; dmChannelId: string }
| { type: 'dm_call_undeliverable'; dmChannelId: string | null; federatedCallId: string; terminal: boolean; phase: DmCallPhase; failures: DmCallUndeliverableFailure[] } | { 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: '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_created'; dmChannel: DmChannel }
| { type: 'dm_channel_closed'; dmChannelId: string } | { type: 'dm_channel_closed'; dmChannelId: string }
| { type: 'dm_channel_updated'; dmChannelId: string; name: string | null; icon: string | null } | { type: 'dm_channel_updated'; dmChannelId: string; name: string | null; icon: string | null }
+22
View File
@@ -75,6 +75,15 @@ import type {
} from '@backspace/shared'; } from '@backspace/shared';
import type { AuditEvent } from '@backspace/shared/src/audit.js'; 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 { export interface StatsLeader {
userId: string; userId: string;
username: string; username: string;
@@ -302,6 +311,12 @@ export class BackspaceApiClient {
removeFavorite: (id: string) => Promise<void>; 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: { readonly stats: {
space: (spaceId: string, days: number) => Promise<SpaceStats>; 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 = { this.stats = {
space: (spaceId: string, days: number) => space: (spaceId: string, days: number) =>
request<SpaceStats>('GET', `/spaces/${spaceId}/stats?days=${days}`), request<SpaceStats>('GET', `/spaces/${spaceId}/stats?days=${days}`),
+28
View File
@@ -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> { async playSound(name: string, options: { loop?: boolean; volume?: number } = {}): Promise<AudioBufferSourceNode | null> {
await this.resumeContext(); await this.resumeContext();
const buffer = await this.loadSound(name); 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 { useAuthStore } from '../../stores/authStore';
import { useInstanceStore } from '../../stores/instanceStore'; import { useInstanceStore } from '../../stores/instanceStore';
import { VoiceChannel } from '../voice/VoiceChannel'; import { VoiceChannel } from '../voice/VoiceChannel';
import { AccountMenu } from './AccountMenu';
import { VoiceControls } from '../voice/VoiceControls'; import { VoiceControls } from '../voice/VoiceControls';
import { useVoiceStore } from '../../stores/voiceStore'; import { useVoiceStore } from '../../stores/voiceStore';
import { ProfileAvatar } from '../ui/ProfileAvatar'; import { ProfileAvatar } from '../ui/ProfileAvatar';
@@ -844,6 +845,7 @@ function UserAreaPanel({
onDeafenToggle: () => void; onDeafenToggle: () => void;
onSettingsClick: (tab?: string) => void; onSettingsClick: (tab?: string) => void;
}) { }) {
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null); const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null);
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
const outputDeviceId = useVoiceStore((s) => s.outputDeviceId); const outputDeviceId = useVoiceStore((s) => s.outputDeviceId);
@@ -1132,14 +1134,27 @@ function UserAreaPanel({
)} )}
{/* User area bar */} {/* User area bar */}
<div className="h-[52px] px-2 flex items-center select-none"> <div className="relative h-[52px] px-2 flex items-center select-none">
{/* Avatar + name */} {accountMenuOpen && (
<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"> <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} /> <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-[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 className="text-[11px] text-txt-tertiary truncate leading-tight group-hover:text-txt-secondary">@{user.username}</div>
</div> </button>
</div> </div>
{/* Controls */} {/* 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 React, { useCallback, useMemo } from 'react';
import { useVoiceStore } from '../../stores/voiceStore'; import { useVoiceStore } from '../../stores/voiceStore';
import { CallTimer } from './CallTimer';
import { useSpaceStore } from '../../stores/spaceStore'; import { useSpaceStore } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore'; 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). */ /** Wrapper component for the volume slider so it can use hooks (useState). */
export function VoiceChannel({ channelId, channelName, onClick, locked, canManage, onSettingsClick, voiceUserHandlers, dropZone }: VoiceChannelProps) { 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 serverVoiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId); const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
const participants = useVoiceStore((s) => s.participants); const participants = useVoiceStore((s) => s.participants);
@@ -141,6 +145,12 @@ export function VoiceChannel({ channelId, channelName, onClick, locked, canManag
</svg> </svg>
)} )}
<span className="truncate text-[15px] font-medium flex-1 text-left">{channelName}</span> <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 && ( {canManage && (
<svg <svg
width="16" width="16"
@@ -7,6 +7,7 @@ import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket'; import { wsSend } from '../../hooks/useWebSocket';
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover'; import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
import { ConnectionInfoPopover } from './ConnectionInfoPopover'; import { ConnectionInfoPopover } from './ConnectionInfoPopover';
import { SoundboardPopover } from './SoundboardPopover';
import { startScreenShare, stopScreenShare } from '../../utils/screenShare'; import { startScreenShare, stopScreenShare } from '../../utils/screenShare';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { broadcastVoiceStatus } from '../../utils/voice'; import { broadcastVoiceStatus } from '../../utils/voice';
@@ -22,6 +23,7 @@ export function VoiceControls() {
const currentVoiceChannelName = useVoiceStore((s) => s.currentVoiceChannelName); const currentVoiceChannelName = useVoiceStore((s) => s.currentVoiceChannelName);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel); const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const navigate = useNavigate(); const navigate = useNavigate();
const [showSoundboard, setShowSoundboard] = useState(false);
const isCameraOn = useVoiceStore((s) => s.isCameraOn); const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled); const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
@@ -119,6 +121,19 @@ export function VoiceControls() {
return ( 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 */} {/* Row 1: Signal icon + status text + disconnect */}
<div className="relative flex items-center gap-2 px-3 pt-3 pb-1"> <div className="relative flex items-center gap-2 px-3 pt-3 pb-1">
<button <button
@@ -216,6 +231,23 @@ export function VoiceControls() {
</button> </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 */} {/* Video Quality */}
<button <button
ref={qualityBtnRef} ref={qualityBtnRef}
+27 -1
View File
@@ -1,4 +1,7 @@
import React, { useEffect, useRef } from 'react'; 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 { useAuthStore } from '../stores/authStore';
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin, setMyUserIdForOrigin, resolveDmChannelId } from '../stores/spaceStore'; import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin, setMyUserIdForOrigin, resolveDmChannelId } from '../stores/spaceStore';
import { useChatStore } from '../stores/chatStore'; import { useChatStore } from '../stores/chatStore';
@@ -285,6 +288,12 @@ function handleEvent(origin: string, event: ServerEvent): void {
setVoiceUsers(channelId, userIds); 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 // Initialize activity data from ready payload
if (event.userActivities) { if (event.userActivities) {
useActivityStore.getState().initActivities(event.userActivities); useActivityStore.getState().initActivities(event.userActivities);
@@ -574,14 +583,31 @@ function handleEvent(origin: string, event: ServerEvent): void {
break; break;
} }
case 'voice_state_update': case 'voice_state_update': {
const vs = useVoiceStore.getState();
if (event.action === 'join') { if (event.action === 'join') {
addVoiceUser(event.channelId, event.userId); addVoiceUser(event.channelId, event.userId);
if (event.startedAt) vs.setVoiceRoomStart(event.channelId, event.startedAt);
} else { } else {
removeVoiceUser(event.channelId, event.userId); removeVoiceUser(event.channelId, event.userId);
clearVoiceUserStatus(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; 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': case 'voice_status_update':
setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened, event.isCameraOn, event.isScreenSharing); setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened, event.isCameraOn, event.isScreenSharing);
+20
View File
@@ -33,6 +33,26 @@ export const en = {
'settings.voice.micTest.idle': 'Test your mic without joining a call.', '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.', '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 // Statistics
'stats.title': 'Statistics', 'stats.title': 'Statistics',
'stats.range.7': 'Last 7 days', 'stats.range.7': 'Last 7 days',
+20
View File
@@ -32,6 +32,26 @@ export const ptBR: Partial<Dictionary> = {
'settings.voice.micTest.idle': 'Teste seu microfone sem entrar numa call.', '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.', '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 // Estatísticas
'stats.title': 'Estatísticas', 'stats.title': 'Estatísticas',
'stats.range.7': 'Últimos 7 dias', 'stats.range.7': 'Últimos 7 dias',
+26
View File
@@ -16,6 +16,12 @@ export interface ScreenShareConfig {
interface VoiceState { interface VoiceState {
voiceUsers: Map<string, string[]>; // channelId → userIds 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; currentVoiceChannelId: string | null;
/** /**
* Space and name of the channel the call is in, captured at join time. * 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; setVoiceUsers: (channelId: string, userIds: string[]) => void;
addVoiceUser: (channelId: string, userId: string) => void; addVoiceUser: (channelId: string, userId: string) => void;
removeVoiceUser: (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; setCurrentVoiceChannel: (channelId: string | null, spaceId?: string | null, channelName?: string | null) => void;
setParticipants: (participants: ParticipantInfo[]) => void; setParticipants: (participants: ParticipantInfo[]) => void;
setSpeakingParticipants: (ids: Set<string>) => void; setSpeakingParticipants: (ids: Set<string>) => void;
@@ -166,6 +174,7 @@ export const useVoiceStore = create<VoiceState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
voiceUsers: new Map(), voiceUsers: new Map(),
voiceRoomStarts: new Map(),
currentVoiceChannelId: null, currentVoiceChannelId: null,
currentVoiceSpaceId: null, currentVoiceSpaceId: null,
currentVoiceChannelName: 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({ setCurrentVoiceChannel: (channelId, spaceId = null, channelName = null) => set({
currentVoiceChannelId: channelId, currentVoiceChannelId: channelId,
currentVoiceSpaceId: channelId ? spaceId : null, currentVoiceSpaceId: channelId ? spaceId : null,
@@ -527,6 +552,7 @@ export const useVoiceStore = create<VoiceState>()(
// Connection state // Connection state
hwOverdrive: false, hwOverdrive: false,
voiceUsers: new Map(), voiceUsers: new Map(),
voiceRoomStarts: new Map(),
voiceUserStates: new Map(), voiceUserStates: new Map(),
currentVoiceChannelId: null, currentVoiceChannelId: null,
currentVoiceSpaceId: null, currentVoiceSpaceId: null,
+7
View File
@@ -9,6 +9,7 @@ import { useVoiceStore } from '../stores/voiceStore';
export interface SpaceVoiceStateSnapshot { export interface SpaceVoiceStateSnapshot {
spaceId: string; spaceId: string;
voiceStates: Record<string, string[]>; voiceStates: Record<string, string[]>;
voiceRoomStarts?: Record<string, number>;
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: 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)) { for (const [channelId, userIds] of Object.entries(snapshot.voiceStates)) {
setVoiceUsers(channelId, userIds); 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)) { for (const [userId, status] of Object.entries(snapshot.voiceUserStates)) {
setVoiceUserStatus(userId, status.isMuted, status.isDeafened, status.isCameraOn, status.isScreenSharing); setVoiceUserStatus(userId, status.isMuted, status.isDeafened, status.isCameraOn, status.isScreenSharing);
} }