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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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':
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user