diff --git a/packages/server/src/routes/livekit.ts b/packages/server/src/routes/livekit.ts index 95944d21..e01b6a49 100644 --- a/packages/server/src/routes/livekit.ts +++ b/packages/server/src/routes/livekit.ts @@ -2,26 +2,36 @@ import type { FastifyInstance } from 'fastify'; import { AccessToken } from 'livekit-server-sdk'; import { authenticate } from '../utils/auth.js'; import { config } from '../config.js'; -import { getChannelServerId, isMember } from '../utils/permissions.js'; +import { getChannelServerId, isMember, isDmMember } from '../utils/permissions.js'; import type { LiveKitTokenRequest, LiveKitTokenResponse } from '@opencord/shared'; export async function livekitRoutes(app: FastifyInstance): Promise { - app.post<{ Body: LiveKitTokenRequest }>('/api/livekit/token', { + app.post<{ Body: LiveKitTokenRequest & { dmChannelId?: string } }>('/api/livekit/token', { preHandler: authenticate, }, async (request, reply) => { - const { channelId } = request.body; + const { channelId, dmChannelId } = request.body as { channelId?: string; dmChannelId?: string }; - if (!channelId || typeof channelId !== 'string') { - return reply.code(400).send({ error: 'channelId is required', statusCode: 400 }); - } + // Determine room name based on channel type + let roomName: string; - const serverId = getChannelServerId(channelId); - if (!serverId) { - return reply.code(404).send({ error: 'Channel not found', statusCode: 404 }); - } - - if (!isMember(serverId, request.userId)) { - return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 }); + if (dmChannelId && typeof dmChannelId === 'string') { + // DM call token + if (!isDmMember(dmChannelId, request.userId)) { + return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 }); + } + roomName = `dm-${dmChannelId}`; + } else if (channelId && typeof channelId === 'string') { + // Server voice channel token + const serverId = getChannelServerId(channelId); + if (!serverId) { + return reply.code(404).send({ error: 'Channel not found', statusCode: 404 }); + } + if (!isMember(serverId, request.userId)) { + return reply.code(403).send({ error: 'You are not a member of this server', statusCode: 403 }); + } + roomName = channelId; + } else { + return reply.code(400).send({ error: 'channelId or dmChannelId is required', statusCode: 400 }); } const identity = `${request.userId}:${request.username}`; @@ -32,7 +42,7 @@ export async function livekitRoutes(app: FastifyInstance): Promise { }); token.addGrant({ - room: channelId, + room: roomName, roomJoin: true, canPublish: true, canSubscribe: true, @@ -41,8 +51,6 @@ export async function livekitRoutes(app: FastifyInstance): Promise { const jwt = await token.toJwt(); - // Use the request's Host header so the LiveKit WSS URL matches however - // the client reached us (domain or direct IP). const requestHost = request.headers.host?.replace(/:\d+$/, '') || ''; const livekitUrl = requestHost ? `wss://${requestHost}/livekit` diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index a81e4a6d..804b0a01 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -144,6 +144,18 @@ export function handleClientEvent( case 'channel_ack': handleChannelAck(event, userId); break; + case 'dm_call_start': + handleDmCallStart(event, userId, username); + break; + case 'dm_call_accept': + handleDmCallAccept(event, userId); + break; + case 'dm_call_reject': + handleDmCallReject(event, userId); + break; + case 'dm_call_end': + handleDmCallEnd(event, userId); + break; default: connectionManager.sendToUser(userId, { type: 'error', @@ -725,3 +737,146 @@ function handleChannelAck(event: Record, userId: string): void messageId, }); } + +// ─── DM Call Handlers ────────────────────────────────────────────────────────── + +function handleDmCallStart(event: Record, userId: string, username: string): void { + const dmChannelId = event.dmChannelId as string; + if (!dmChannelId || typeof dmChannelId !== 'string') { + connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' }); + return; + } + + if (!isDmMember(dmChannelId, userId)) { + connectionManager.sendToUser(userId, { type: 'error', message: 'You are not a member of this DM channel' }); + return; + } + + // Try to start the call (fails if already active) + const started = connectionManager.startCall(dmChannelId, userId); + if (!started) { + connectionManager.sendToUser(userId, { type: 'error', message: 'A call is already active in this DM channel' }); + return; + } + + // Find the other DM member(s) and send incoming call notification + const db = getDb(); + const dmMembers = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, dmChannelId)) + .all(); + + for (const member of dmMembers) { + if (member.userId !== userId) { + connectionManager.sendToUser(member.userId, { + type: 'dm_call_incoming', + dmChannelId, + callerId: userId, + callerName: username, + }); + } + } +} + +function handleDmCallAccept(event: Record, userId: string): void { + const dmChannelId = event.dmChannelId as string; + if (!dmChannelId || typeof dmChannelId !== 'string') { + connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' }); + return; + } + + if (!isDmMember(dmChannelId, userId)) { + connectionManager.sendToUser(userId, { type: 'error', message: 'You are not a member of this DM channel' }); + return; + } + + const activeCall = connectionManager.getActiveCall(dmChannelId); + if (!activeCall) { + connectionManager.sendToUser(userId, { type: 'error', message: 'No active call in this DM channel' }); + return; + } + + // Notify all DM members that the call was accepted + const db = getDb(); + const dmMembers = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, dmChannelId)) + .all(); + + for (const member of dmMembers) { + connectionManager.sendToUser(member.userId, { + type: 'dm_call_accepted', + dmChannelId, + }); + } +} + +function handleDmCallReject(event: Record, userId: string): void { + const dmChannelId = event.dmChannelId as string; + if (!dmChannelId || typeof dmChannelId !== 'string') { + connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' }); + return; + } + + if (!isDmMember(dmChannelId, userId)) { + connectionManager.sendToUser(userId, { type: 'error', message: 'You are not a member of this DM channel' }); + return; + } + + const activeCall = connectionManager.getActiveCall(dmChannelId); + if (!activeCall) { + return; // No active call, silently ignore + } + + // End the call since it was rejected + connectionManager.endCall(dmChannelId); + + // Notify all DM members that the call was rejected + const db = getDb(); + const dmMembers = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, dmChannelId)) + .all(); + + for (const member of dmMembers) { + connectionManager.sendToUser(member.userId, { + type: 'dm_call_rejected', + dmChannelId, + }); + } +} + +function handleDmCallEnd(event: Record, userId: string): void { + const dmChannelId = event.dmChannelId as string; + if (!dmChannelId || typeof dmChannelId !== 'string') { + connectionManager.sendToUser(userId, { type: 'error', message: 'dmChannelId is required' }); + return; + } + + if (!isDmMember(dmChannelId, userId)) { + connectionManager.sendToUser(userId, { type: 'error', message: 'You are not a member of this DM channel' }); + return; + } + + const activeCall = connectionManager.getActiveCall(dmChannelId); + if (!activeCall) { + return; // No active call, silently ignore + } + + // End the call + connectionManager.endCall(dmChannelId); + + // Notify all DM members that the call ended + const db = getDb(); + const dmMembers = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, dmChannelId)) + .all(); + + for (const member of dmMembers) { + connectionManager.sendToUser(member.userId, { + type: 'dm_call_ended', + dmChannelId, + }); + } +} diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 5e9df05d..893b3165 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -42,6 +42,8 @@ class ConnectionManager { private voiceStates: Map> = new Map(); // ws → userId (reverse lookup) private wsToUser: Map = new Map(); + // dmChannelId → { callerId, startedAt } — active DM calls + private activeCalls: Map = new Map(); addConnection(userId: string, ws: WebSocket): void { if (!this.connections.has(userId)) { @@ -136,6 +138,21 @@ class ConnectionManager { return null; } + // DM call management + startCall(dmChannelId: string, callerId: string): boolean { + if (this.activeCalls.has(dmChannelId)) return false; // Already in a call + this.activeCalls.set(dmChannelId, { callerId, startedAt: Date.now() }); + return true; + } + + endCall(dmChannelId: string): void { + this.activeCalls.delete(dmChannelId); + } + + getActiveCall(dmChannelId: string): { callerId: string; startedAt: number } | undefined { + return this.activeCalls.get(dmChannelId); + } + // Send to a specific user (all their connections) sendToUser(userId: string, event: ServerEvent): void { const connections = this.getUserConnections(userId); diff --git a/packages/server/verify-livekit.ts b/packages/server/verify-livekit.ts new file mode 100644 index 00000000..15c570e6 --- /dev/null +++ b/packages/server/verify-livekit.ts @@ -0,0 +1,121 @@ +/** + * LiveKit Verification Script + * + * Proves that: + * 1. Environment variables load correctly + * 2. AccessToken generates a valid JWT + * 3. The LiveKit server is reachable and accepts our credentials + * + * Run: npx tsx packages/server/verify-livekit.ts + */ + +import { config as dotenvConfig } from 'dotenv'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { AccessToken, RoomServiceClient } from 'livekit-server-sdk'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +dotenvConfig({ path: resolve(__dirname, '../../.env') }); + +const LIVEKIT_URL = process.env.LIVEKIT_URL || 'wss://nova.ddns.net/livekit'; +const API_KEY = process.env.LIVEKIT_API_KEY || 'REDACTED_LIVEKIT_KEY'; +const API_SECRET = process.env.LIVEKIT_API_SECRET || 'REDACTED_LIVEKIT_SECRET'; + +async function verify() { + console.log('=== LiveKit Verification ==='); + console.log(`URL: ${LIVEKIT_URL}`); + console.log(`API Key: ${API_KEY}`); + console.log(`Secret: ${API_SECRET.slice(0, 4)}...${API_SECRET.slice(-4)}`); + console.log(''); + + // Step 1: Generate a token + console.log('[1/3] Generating AccessToken...'); + const token = new AccessToken(API_KEY, API_SECRET, { + identity: 'verify-user:verifier', + ttl: '5m', + }); + token.addGrant({ + room: 'verification-room', + roomJoin: true, + canPublish: true, + canSubscribe: true, + canPublishData: true, + }); + const jwt = await token.toJwt(); + console.log(` Token generated (${jwt.length} chars): ${jwt.slice(0, 40)}...`); + console.log(' ✓ Token generation works'); + console.log(''); + + // Step 2: Decode and validate the JWT structure + console.log('[2/3] Validating JWT structure...'); + const parts = jwt.split('.'); + if (parts.length !== 3) { + throw new Error(`Invalid JWT structure: expected 3 parts, got ${parts.length}`); + } + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()); + console.log(` sub (identity): ${payload.sub}`); + console.log(` video grants: ${JSON.stringify(payload.video)}`); + if (payload.video?.room !== 'verification-room') { + throw new Error(`Room grant mismatch: expected "verification-room", got "${payload.video?.room}"`); + } + if (!payload.video?.roomJoin) { + throw new Error('roomJoin grant is missing or false'); + } + console.log(' ✓ JWT payload is valid'); + console.log(''); + + // Step 3: Connect to LiveKit server via RoomServiceClient + console.log('[3/3] Connecting to LiveKit server...'); + // The LiveKit server runs on the Pi at 192.168.1.10:7880 (host network mode). + // The DDNS domain (nova.ddns.net) routes externally but may not loop back on LAN. + // Try the LAN address first, then fall back to the configured URL. + const lanUrl = 'http://192.168.1.10:7880'; + const wanUrl = LIVEKIT_URL.replace('wss://', 'https://').replace('ws://', 'http://'); + + let roomService: RoomServiceClient; + let usedUrl: string; + try { + roomService = new RoomServiceClient(lanUrl, API_KEY, API_SECRET); + const rooms = await roomService.listRooms(); + usedUrl = lanUrl; + console.log(` Server responded via LAN (${lanUrl}). Active rooms: ${rooms.length}`); + for (const room of rooms) { + console.log(` - ${room.name} (${room.numParticipants} participants)`); + } + } catch { + console.log(` LAN address unreachable, trying WAN (${wanUrl})...`); + roomService = new RoomServiceClient(wanUrl, API_KEY, API_SECRET); + const rooms = await roomService.listRooms(); + usedUrl = wanUrl; + console.log(` Server responded via WAN (${wanUrl}). Active rooms: ${rooms.length}`); + for (const room of rooms) { + console.log(` - ${room.name} (${room.numParticipants} participants)`); + } + } + console.log(` ✓ LiveKit server is reachable at ${usedUrl} and credentials are valid`); + console.log(''); + + // Step 4: Verify the WSS proxy endpoint specifically + console.log('[4/4] Verifying WSS proxy endpoint (the path browsers use)...'); + const wssProxyUrl = LIVEKIT_URL.replace('wss://', 'https://').replace('ws://', 'http://'); + try { + const wssService = new RoomServiceClient(wssProxyUrl, API_KEY, API_SECRET); + const wssRooms = await wssService.listRooms(); + console.log(` WSS proxy (${wssProxyUrl}) responded. Active rooms: ${wssRooms.length}`); + console.log(' ✓ WSS proxy is working — browsers can reach LiveKit'); + } catch (wssErr) { + console.log(` WSS proxy (${wssProxyUrl}) failed: ${wssErr instanceof Error ? wssErr.message : wssErr}`); + console.log(' ⚠ WSS proxy is down, but LAN direct access works. Browsers may fail if they resolve to the external IP.'); + } + console.log(''); + + console.log('LIVEKIT VERIFICATION SUCCESS'); +} + +verify().catch((err) => { + console.error(''); + console.error('LIVEKIT VERIFICATION FAILED'); + console.error(`Error: ${err.message}`); + if (err.cause) console.error(`Cause: ${err.cause}`); + process.exit(1); +}); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index f490d150..ae3669e9 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -181,7 +181,11 @@ export type ClientEvent = | { type: 'dm_message_delete'; messageId: string } | { type: 'reaction_add'; messageId: string; emoji: string } | { type: 'reaction_remove'; messageId: string; emoji: string } - | { type: 'channel_ack'; channelId: string; messageId: string }; + | { type: 'channel_ack'; channelId: string; messageId: string } + | { type: 'dm_call_start'; dmChannelId: string } + | { type: 'dm_call_accept'; dmChannelId: string } + | { type: 'dm_call_reject'; dmChannelId: string } + | { type: 'dm_call_end'; dmChannelId: string }; // Server → Client Events export type ServerEvent = @@ -203,6 +207,10 @@ export type ServerEvent = | { type: 'channel_ack'; channelId: string; messageId: string } | { type: 'friend_request_received'; request: FriendRequest } | { type: 'friend_request_accepted'; friend: Friend; requestId: string } + | { type: 'dm_call_incoming'; dmChannelId: string; callerId: string; callerName: string } + | { type: 'dm_call_accepted'; dmChannelId: string } + | { type: 'dm_call_rejected'; dmChannelId: string } + | { type: 'dm_call_ended'; dmChannelId: string } | { type: 'error'; message: string }; // ─── API Request/Response Types ───────────────────────────────────────────── diff --git a/packages/web/src/api/client.js b/packages/web/src/api/client.js index cca01d47..5ce79dc6 100644 --- a/packages/web/src/api/client.js +++ b/packages/web/src/api/client.js @@ -113,5 +113,6 @@ export const api = { }, livekit: { token: (channelId) => request('POST', '/livekit/token', { channelId }), + dmToken: (dmChannelId) => request('POST', '/livekit/token', { dmChannelId }), }, }; diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index cc2db245..e49f932b 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -177,5 +177,7 @@ export const api = { livekit: { token: (channelId: string) => request('POST', '/livekit/token', { channelId }), + dmToken: (dmChannelId: string) => + request('POST', '/livekit/token', { dmChannelId }), }, }; diff --git a/packages/web/src/components/chat/FriendsPage.test.js b/packages/web/src/components/chat/FriendsPage.test.js new file mode 100644 index 00000000..257d656e --- /dev/null +++ b/packages/web/src/components/chat/FriendsPage.test.js @@ -0,0 +1,265 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { FriendsPage } from './FriendsPage'; +import { useSocialStore } from '../../stores/socialStore'; +import { useServerStore } from '../../stores/serverStore'; +// Mock the api module +vi.mock('../../api/client', () => ({ + api: { + dm: { + create: vi.fn(), + }, + social: { + friends: vi.fn().mockResolvedValue([]), + requests: vi.fn().mockResolvedValue([]), + sendRequest: vi.fn().mockResolvedValue({ success: true }), + updateRequest: vi.fn().mockResolvedValue({ success: true }), + cancelRequest: vi.fn().mockResolvedValue({ success: true }), + removeFriend: vi.fn().mockResolvedValue({ success: true }), + search: vi.fn().mockResolvedValue([]), + }, + }, +})); +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); +const makeFriend = (overrides = {}) => ({ + id: 'friend-1', + username: 'testfriend', + displayName: 'Test Friend', + avatar: null, + status: 'online', + customStatus: null, + createdAt: Date.now(), + addedAt: Date.now(), + ...overrides, +}); +const makeRequest = (overrides = {}) => ({ + id: 'req-1', + fromId: 'other-user', + toId: 'current-user', + status: 'pending', + createdAt: Date.now(), + user: { + id: 'other-user', + username: 'otheruser', + displayName: 'Other User', + avatar: null, + status: 'online', + customStatus: null, + createdAt: Date.now(), + }, + ...overrides, +}); +function renderFriendsPage() { + return render(_jsx(MemoryRouter, { children: _jsx(FriendsPage, {}) })); +} +beforeEach(() => { + mockNavigate.mockClear(); + // Reset the social store with no-op loaders (we set state directly) + useSocialStore.setState({ + friends: [], + requests: [], + isLoading: false, + error: null, + loadFriends: vi.fn(), + loadRequests: vi.fn(), + }); + useServerStore.setState({ + dmChannels: [], + }); +}); +describe('FriendsPage', () => { + describe('Add Friend tab', () => { + it('renders the Add Friend form when tab is clicked', async () => { + const user = userEvent.setup(); + renderFriendsPage(); + const addFriendTab = screen.getByText('Add Friend'); + await user.click(addFriendTab); + expect(screen.getByPlaceholderText('You can add a friend with their username')).toBeInTheDocument(); + expect(screen.getByText('Send Friend Request')).toBeInTheDocument(); + }); + it('calls sendFriendRequest with the username when form is submitted', async () => { + const user = userEvent.setup(); + const mockSendFriendRequest = vi.fn().mockResolvedValue(undefined); + useSocialStore.setState({ + sendFriendRequest: mockSendFriendRequest, + }); + renderFriendsPage(); + // Switch to Add Friend tab + await user.click(screen.getByText('Add Friend')); + // Type username + const input = screen.getByPlaceholderText('You can add a friend with their username'); + await user.type(input, 'newbuddy'); + // Click send + await user.click(screen.getByText('Send Friend Request')); + await waitFor(() => { + expect(mockSendFriendRequest).toHaveBeenCalledWith('newbuddy'); + }); + // Should show success message + await waitFor(() => { + expect(screen.getByText(/Success! Your friend request to newbuddy has been sent/)).toBeInTheDocument(); + }); + }); + it('shows error when sendFriendRequest fails', async () => { + const user = userEvent.setup(); + const mockSendFriendRequest = vi.fn().mockRejectedValue(new Error('User not found')); + useSocialStore.setState({ + sendFriendRequest: mockSendFriendRequest, + }); + renderFriendsPage(); + await user.click(screen.getByText('Add Friend')); + const input = screen.getByPlaceholderText('You can add a friend with their username'); + await user.type(input, 'ghost'); + await user.click(screen.getByText('Send Friend Request')); + await waitFor(() => { + expect(screen.getByText('User not found')).toBeInTheDocument(); + }); + }); + }); + describe('DM button on friend item', () => { + it('calls api.dm.create and navigates when clicking the Message button', async () => { + const user = userEvent.setup(); + const friend = makeFriend({ id: 'friend-42', username: 'dmpal', displayName: 'DM Pal' }); + const mockAddDmChannel = vi.fn(); + useSocialStore.setState({ + friends: [friend], + requests: [], + }); + useServerStore.setState({ + addDmChannel: mockAddDmChannel, + }); + // Mock the dm.create API + const { api } = await import('../../api/client'); + api.dm.create.mockResolvedValue({ + id: 'dm-channel-99', + createdAt: Date.now(), + members: [], + }); + renderFriendsPage(); + // Switch to "All" tab to see the friend + await user.click(screen.getByText('All')); + // Find the Message button by title + const dmButton = screen.getByTitle('Message'); + await user.click(dmButton); + await waitFor(() => { + expect(api.dm.create).toHaveBeenCalledWith({ userId: 'friend-42' }); + }); + await waitFor(() => { + expect(mockAddDmChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'dm-channel-99' })); + }); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/channels/@me/dm-channel-99'); + }); + }); + }); + describe('Cancel outgoing friend request', () => { + it('calls cancelFriendRequest when clicking cancel on an outgoing request', async () => { + const user = userEvent.setup(); + const mockCancel = vi.fn().mockResolvedValue(undefined); + // Outgoing request: user.id === toId means current user sent it (fromId is current user, user is the recipient) + const outgoingRequest = makeRequest({ + id: 'req-out-1', + fromId: 'current-user', + toId: 'other-user', + user: { + id: 'other-user', + username: 'recipient', + displayName: 'Recipient', + avatar: null, + status: 'online', + customStatus: null, + createdAt: Date.now(), + }, + }); + useSocialStore.setState({ + friends: [], + requests: [outgoingRequest], + cancelFriendRequest: mockCancel, + }); + renderFriendsPage(); + // Switch to Pending tab + await user.click(screen.getByText('Pending')); + // Should see the outgoing request + expect(screen.getByText('Outgoing Friend Request')).toBeInTheDocument(); + // Click the cancel button (the X icon button with title "Cancel Request") + const cancelButton = screen.getByTitle('Cancel Request'); + await user.click(cancelButton); + await waitFor(() => { + expect(mockCancel).toHaveBeenCalledWith('req-out-1'); + }); + }); + }); + describe('Accept/Decline incoming friend request', () => { + it('calls updateFriendRequest with "accepted" when clicking accept', async () => { + const user = userEvent.setup(); + const mockUpdate = vi.fn().mockResolvedValue(undefined); + const incomingRequest = makeRequest({ + id: 'req-in-1', + fromId: 'sender-id', + toId: 'current-user', + user: { + id: 'sender-id', + username: 'sender', + displayName: 'Sender', + avatar: null, + status: 'online', + customStatus: null, + createdAt: Date.now(), + }, + }); + useSocialStore.setState({ + friends: [], + requests: [incomingRequest], + updateFriendRequest: mockUpdate, + }); + renderFriendsPage(); + await user.click(screen.getByText('Pending')); + expect(screen.getByText('Incoming Friend Request')).toBeInTheDocument(); + // Click accept button (title "Accept") + const acceptButton = screen.getByTitle('Accept'); + await user.click(acceptButton); + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalledWith('req-in-1', 'accepted'); + }); + }); + it('calls updateFriendRequest with "declined" when clicking decline', async () => { + const user = userEvent.setup(); + const mockUpdate = vi.fn().mockResolvedValue(undefined); + const incomingRequest = makeRequest({ + id: 'req-in-2', + fromId: 'sender-id', + toId: 'current-user', + user: { + id: 'sender-id', + username: 'sender2', + displayName: 'Sender 2', + avatar: null, + status: 'online', + customStatus: null, + createdAt: Date.now(), + }, + }); + useSocialStore.setState({ + friends: [], + requests: [incomingRequest], + updateFriendRequest: mockUpdate, + }); + renderFriendsPage(); + await user.click(screen.getByText('Pending')); + const declineButton = screen.getByTitle('Decline'); + await user.click(declineButton); + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalledWith('req-in-2', 'declined'); + }); + }); + }); +}); diff --git a/packages/web/src/components/chat/MessageInput.js b/packages/web/src/components/chat/MessageInput.js index 8a972474..aeb4cee6 100644 --- a/packages/web/src/components/chat/MessageInput.js +++ b/packages/web/src/components/chat/MessageInput.js @@ -20,7 +20,8 @@ export function MessageInput({ channelId, channelName }) { const isDm = isDmChannel(channelId); if (isDm) { wsSend({ type: 'dm_typing_start', dmChannelId: channelId }); - } else { + } + else { wsSend({ type: 'typing_start', channelId }); } typingTimeoutRef.current = setTimeout(() => { @@ -103,5 +104,5 @@ export function MessageInput({ channelId, channelName }) { setFiles((prev) => [...prev, ...selected]); } e.target.value = ''; - } }), _jsx("textarea", { ref: textareaRef, value: content, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, placeholder: `Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`, className: "flex-1 py-[11px] px-1 bg-transparent text-discord-text-primary placeholder-discord-text-muted/60 outline-none resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin", rows: 1, disabled: isUploading }), isUploading && (_jsx("div", { className: "p-3 text-discord-text-muted", children: _jsxs("svg", { className: "w-5 h-5 animate-spin", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }) })), _jsx("button", { className: "p-3 text-discord-text-muted hover:text-discord-text-secondary transition-colors", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" }) }) }), _jsx("button", { onClick: handleSubmit, disabled: !content.trim() && files.length === 0, className: "p-3 text-discord-text-muted hover:text-discord-text-primary transition-colors disabled:opacity-30 disabled:hover:text-discord-text-muted", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" }) }) })] })] })] })); + } }), _jsx("textarea", { ref: textareaRef, value: content, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, placeholder: `Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`, className: "flex-1 py-[11px] px-1 bg-transparent text-discord-text-primary placeholder-discord-text-muted/60 outline-none resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin", rows: 1, disabled: isUploading }), isUploading && (_jsx("div", { className: "p-3 text-discord-text-muted", children: _jsxs("svg", { className: "w-5 h-5 animate-spin", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }) })), _jsx("button", { className: "p-2 text-discord-text-muted hover:text-discord-text-secondary transition-colors", title: "GIF", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M2 5.5A2.5 2.5 0 0 1 4.5 3h15A2.5 2.5 0 0 1 22 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 18.5v-13ZM5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" }) }) }), _jsx("button", { className: "p-2 text-discord-text-muted hover:text-discord-text-secondary transition-colors", title: "Stickers", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12.5 2C6.81 2 2 6.81 2 12.5S6.81 23 12.5 23c1.31 0 2.56-.25 3.73-.7l5.07-5.07c.45-1.17.7-2.42.7-3.73C22 7.81 17.19 2 12.5 2Zm0 19c-4.69 0-8.5-3.81-8.5-8.5S7.81 4 12.5 4 21 7.81 21 12.5c0 .89-.14 1.74-.4 2.54l-3.56 3.56c-.8.26-1.65.4-2.54.4ZM8 11.5c.83 0 1.5-.67 1.5-1.5S8.83 8.5 8 8.5 6.5 9.17 6.5 10s.67 1.5 1.5 1.5Zm6 0c.83 0 1.5-.67 1.5-1.5s-.67-1.5-1.5-1.5-1.5.67-1.5 1.5.67 1.5 1.5 1.5Zm-1 3.5c-2.33 0-4.31-1.46-5.11-3.5h10.22c-.8 2.04-2.78 3.5-5.11 3.5Z" }) }) }), _jsx("button", { className: "p-2 text-discord-text-muted hover:text-discord-text-secondary transition-colors", title: "Emoji", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" }) }) })] })] })] })); } diff --git a/packages/web/src/components/chat/MessageInput.tsx b/packages/web/src/components/chat/MessageInput.tsx index 1bb2a8ba..8e62f352 100644 --- a/packages/web/src/components/chat/MessageInput.tsx +++ b/packages/web/src/components/chat/MessageInput.tsx @@ -215,21 +215,24 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { )} - {/* Emoji button placeholder */} - - {/* Send Button */} - + + {/* Emoji button */} + diff --git a/packages/web/src/components/chat/MessageList.js b/packages/web/src/components/chat/MessageList.js index 9330c26f..471c0020 100644 --- a/packages/web/src/components/chat/MessageList.js +++ b/packages/web/src/components/chat/MessageList.js @@ -2,6 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import React, { useEffect, useRef, useCallback, useState } from 'react'; import { Message } from './Message'; import { useChatStore } from '../../stores/chatStore'; +import { useServerStore, isDmChannel } from '../../stores/serverStore'; +import { useAuthStore } from '../../stores/authStore'; +import { Avatar } from '../ui/Avatar'; import { LoadingSpinner } from '../ui/LoadingSpinner'; const EMPTY_MESSAGES = []; function isSameGroup(prev, curr) { @@ -42,6 +45,7 @@ export function MessageList({ channelId }) { useEffect(() => { loadMessages(channelId); }, [channelId, loadMessages]); + // Ack channel when messages load or when new messages arrive while near bottom useEffect(() => { if (messages.length > 0 && isNearBottom) { clearTimeout(ackTimerRef.current); @@ -86,10 +90,23 @@ export function MessageList({ channelId }) { if (isLoading && messages.length === 0) { return (_jsx("div", { className: "flex-1 flex items-center justify-center", children: _jsx(LoadingSpinner, {}) })); } - return (_jsxs("div", { ref: containerRef, className: "flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin", onScroll: handleScroll, children: [isLoadingMore && (_jsx("div", { className: "py-4", children: _jsx(LoadingSpinner, { size: 24 }) })), !hasMore && (_jsxs("div", { className: "px-4 pt-8 pb-4", children: [_jsx("div", { className: "w-[68px] h-[68px] rounded-full bg-discord-bg-accent flex items-center justify-center mb-4 text-white", children: _jsx("svg", { width: "42", height: "42", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }) }), _jsx("h3", { className: "text-[32px] leading-10 font-bold text-discord-text-primary", children: "Welcome to the channel!" }), _jsx("p", { className: "text-discord-text-secondary text-[16px] mt-2", children: "This is the start of the conversation." }), _jsx("div", { className: "mt-6 border-b border-discord-modifier-accent" })] })), _jsx("div", { className: "pb-6", children: messages.map((msg, i) => { + return (_jsxs("div", { ref: containerRef, className: "flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin", onScroll: handleScroll, children: [isLoadingMore && (_jsx("div", { className: "py-4", children: _jsx(LoadingSpinner, { size: 24 }) })), !hasMore && _jsx(WelcomeHeader, { channelId: channelId }), _jsx("div", { className: "pb-6", children: messages.map((msg, i) => { const prevMsg = messages[i - 1]; const showDate = shouldShowDateDivider(prevMsg, msg); const isFirstInGroup = !prevMsg || showDate || !isSameGroup(prevMsg, msg); return (_jsxs(React.Fragment, { children: [showDate && (_jsxs("div", { className: "flex items-center px-4 my-6 select-none pointer-events-none", children: [_jsx("div", { className: "flex-1 h-[1px] bg-discord-modifier-accent" }), _jsx("span", { className: "px-2 text-[12px] font-bold text-discord-text-muted leading-tight", children: formatDateDivider(msg.createdAt) }), _jsx("div", { className: "flex-1 h-[1px] bg-discord-modifier-accent" })] })), _jsx(Message, { message: msg, isCompact: !isFirstInGroup, isFirstInGroup: isFirstInGroup })] }, msg.id)); }) }), _jsx("div", { ref: bottomRef })] })); } +function WelcomeHeader({ channelId }) { + const dmChannels = useServerStore((s) => s.dmChannels); + const authUser = useAuthStore((s) => s.user); + const isDm = isDmChannel(channelId); + if (isDm) { + const dm = dmChannels.find(d => d.id === channelId); + const otherUser = dm?.members.find(m => m.id !== authUser?.id); + const displayName = otherUser?.displayName ?? otherUser?.username ?? 'Unknown'; + const username = otherUser?.username ?? 'unknown'; + return (_jsxs("div", { className: "px-4 pt-8 pb-4", children: [_jsx("div", { className: "mb-2", children: _jsx(Avatar, { src: otherUser?.avatar, name: displayName, size: 80 }) }), _jsx("h3", { className: "text-[32px] leading-10 font-bold text-discord-text-primary", children: displayName }), _jsxs("p", { className: "text-discord-text-secondary text-[14px] mt-1", children: ["This is the beginning of your direct message history with ", _jsxs("strong", { children: ["@", username] }), "."] }), _jsx("div", { className: "mt-4", children: _jsx("button", { className: "px-4 py-1.5 bg-discord-bg-accent hover:bg-discord-bg-surface-higher text-[14px] font-medium text-discord-text-primary rounded-[3px] transition-colors", children: "Remove Friend" }) }), _jsx("div", { className: "mt-6 border-b border-discord-modifier-accent" })] })); + } + return (_jsxs("div", { className: "px-4 pt-8 pb-4", children: [_jsx("div", { className: "w-[68px] h-[68px] rounded-full bg-discord-bg-accent flex items-center justify-center mb-4 text-white", children: _jsx("svg", { width: "42", height: "42", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }) }), _jsx("h3", { className: "text-[32px] leading-10 font-bold text-discord-text-primary", children: "Welcome to the channel!" }), _jsx("p", { className: "text-discord-text-secondary text-[16px] mt-2", children: "This is the start of the conversation." }), _jsx("div", { className: "mt-6 border-b border-discord-modifier-accent" })] })); +} diff --git a/packages/web/src/components/chat/MessageList.tsx b/packages/web/src/components/chat/MessageList.tsx index d0b8e46a..dadd0ef1 100644 --- a/packages/web/src/components/chat/MessageList.tsx +++ b/packages/web/src/components/chat/MessageList.tsx @@ -1,6 +1,9 @@ import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react'; import { Message } from './Message'; import { useChatStore } from '../../stores/chatStore'; +import { useServerStore, isDmChannel } from '../../stores/serverStore'; +import { useAuthStore } from '../../stores/authStore'; +import { Avatar } from '../ui/Avatar'; import { LoadingSpinner } from '../ui/LoadingSpinner'; import type { MessageWithUser } from '@opencord/shared'; @@ -118,18 +121,7 @@ export function MessageList({ channelId }: MessageListProps) { )} - {!hasMore && ( -
-
- - - -
-

Welcome to the channel!

-

This is the start of the conversation.

-
-
- )} + {!hasMore && }
{messages.map((msg, i) => { @@ -162,3 +154,47 @@ export function MessageList({ channelId }: MessageListProps) {
); } + +function WelcomeHeader({ channelId }: { channelId: string }) { + const dmChannels = useServerStore((s) => s.dmChannels); + const authUser = useAuthStore((s) => s.user); + const isDm = isDmChannel(channelId); + + if (isDm) { + const dm = dmChannels.find(d => d.id === channelId); + const otherUser = dm?.members.find(m => m.id !== authUser?.id); + const displayName = otherUser?.displayName ?? otherUser?.username ?? 'Unknown'; + const username = otherUser?.username ?? 'unknown'; + + return ( +
+
+ +
+

{displayName}

+

+ This is the beginning of your direct message history with @{username}. +

+
+ +
+
+
+ ); + } + + return ( +
+
+ + + +
+

Welcome to the channel!

+

This is the start of the conversation.

+
+
+ ); +} diff --git a/packages/web/src/components/layout/AppLayout.js b/packages/web/src/components/layout/AppLayout.js index 5d7777ea..0d24abe5 100644 --- a/packages/web/src/components/layout/AppLayout.js +++ b/packages/web/src/components/layout/AppLayout.js @@ -13,6 +13,7 @@ import { InviteModal } from '../modals/InviteModal'; import { UserSettingsModal } from '../modals/UserSettings'; import { ServerSettingsModal } from '../modals/ServerSettings'; import { NewDmModal } from '../modals/NewDmModal'; +import { IncomingCallModal } from '../voice/IncomingCallModal'; import { UserProfilePopout } from '../ui/UserProfilePopout'; import { useAuth } from '../../hooks/useAuth'; import { useWebSocket } from '../../hooks/useWebSocket'; @@ -36,23 +37,33 @@ export function AppLayout() { const userProfilePopout = useUIStore((s) => s.userProfilePopout); const closeUserProfile = useUIStore((s) => s.closeUserProfile); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); + const activeDmCall = useVoiceStore((s) => s.activeDmCall); const setParticipants = useVoiceStore((s) => s.setParticipants); - const { connect: connectVoice, disconnect: disconnectVoice, participants: voiceParticipants, } = useLiveKit(); + const { connect: connectVoice, connectDm: connectDmVoice, disconnect: disconnectVoice, participants: voiceParticipants, } = useLiveKit(); // Initialize WebSocket useWebSocket(); // Sync participants to store useEffect(() => { setParticipants(voiceParticipants); }, [voiceParticipants, setParticipants]); - // Manage voice connection + // Manage voice connection (server voice channels) useEffect(() => { if (currentVoiceChannelId) { connectVoice(currentVoiceChannelId); } - else { + else if (!activeDmCall) { disconnectVoice(); } - }, [currentVoiceChannelId, connectVoice, disconnectVoice]); + }, [currentVoiceChannelId, connectVoice, disconnectVoice, activeDmCall]); + // Manage DM call connection + useEffect(() => { + if (activeDmCall) { + connectDmVoice(activeDmCall.dmChannelId); + } + else if (!currentVoiceChannelId) { + disconnectVoice(); + } + }, [activeDmCall, connectDmVoice, disconnectVoice, currentVoiceChannelId]); // Responsive detection useEffect(() => { const checkMobile = () => setIsMobile(window.innerWidth < 768); @@ -89,5 +100,5 @@ export function AppLayout() { if (isLoading || !user) { return (_jsx("div", { className: "h-screen flex items-center justify-center bg-discord-bg-primary", children: _jsxs("div", { className: "text-center", children: [_jsxs("svg", { className: "animate-spin w-10 h-10 text-discord-blurple mx-auto mb-4", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }), _jsx("p", { className: "text-discord-text-muted", children: "Loading Opencord..." })] }) })); } - return (_jsxs("div", { className: "h-screen flex bg-discord-bg-tertiary overflow-hidden", children: [_jsxs("div", { className: `${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : 'flex h-full'}`, children: [_jsx(ServerSidebar, {}), _jsx(ChannelSidebar, {})] }), _jsxs("div", { className: "flex-1 flex min-w-0 bg-discord-bg-primary relative", children: [_jsx(MainContent, {}), serverId === '@me' ? (_jsx("div", { className: "w-[358px] bg-discord-bg-secondary flex-shrink-0 hidden xl:flex flex-col", children: _jsxs("div", { className: "p-4", children: [_jsx("h3", { className: "text-[20px] font-bold text-discord-text-header mb-4", children: "Active Now" }), _jsxs("div", { className: "text-center py-8", children: [_jsx("div", { className: "text-[16px] font-bold text-discord-text-header mb-1 text-center", children: "It's quiet for now..." }), _jsx("div", { className: "text-[14px] text-discord-text-muted text-center max-w-[200px] mx-auto", children: "When a friend starts an activity\u2014like playing a game or hanging out on voice\u2014we\u2019ll show it here!" })] })] }) })) : (_jsx(MemberSidebar, {}))] }), _jsx(CreateServerModal, {}), _jsx(JoinServerModal, {}), _jsx(CreateChannelModal, {}), _jsx(InviteModal, {}), _jsx(UserSettingsModal, {}), _jsx(ServerSettingsModal, {}), _jsx(NewDmModal, {}), _jsx(ImagePreview, {}), userProfilePopout.user && userProfilePopout.position && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-[45]", onClick: closeUserProfile }), _jsx(UserProfilePopout, { user: userProfilePopout.user, onClose: closeUserProfile, position: userProfilePopout.position })] }))] })); + return (_jsxs("div", { className: "h-screen flex bg-discord-bg-tertiary overflow-hidden", children: [_jsxs("div", { className: `${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : 'flex h-full'}`, children: [_jsx(ServerSidebar, {}), _jsx(ChannelSidebar, {})] }), _jsxs("div", { className: "flex-1 flex min-w-0 bg-discord-bg-primary relative", children: [_jsx(MainContent, {}), serverId === '@me' ? (_jsx("div", { className: "w-[358px] bg-discord-bg-secondary flex-shrink-0 hidden xl:flex flex-col", children: _jsxs("div", { className: "p-4", children: [_jsx("h3", { className: "text-[20px] font-bold text-discord-text-header mb-4", children: "Active Now" }), _jsxs("div", { className: "text-center py-8", children: [_jsx("div", { className: "text-[16px] font-bold text-discord-text-header mb-1 text-center", children: "It's quiet for now..." }), _jsx("div", { className: "text-[14px] text-discord-text-muted text-center max-w-[200px] mx-auto", children: "When a friend starts an activity\u2014like playing a game or hanging out on voice\u2014we\u2019ll show it here!" })] })] }) })) : (_jsx(MemberSidebar, {}))] }), _jsx(CreateServerModal, {}), _jsx(JoinServerModal, {}), _jsx(CreateChannelModal, {}), _jsx(InviteModal, {}), _jsx(UserSettingsModal, {}), _jsx(ServerSettingsModal, {}), _jsx(NewDmModal, {}), _jsx(IncomingCallModal, {}), _jsx(ImagePreview, {}), userProfilePopout.user && userProfilePopout.position && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-[45]", onClick: closeUserProfile }), _jsx(UserProfilePopout, { user: userProfilePopout.user, onClose: closeUserProfile, position: userProfilePopout.position })] }))] })); } diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index d0bc763f..28d7f3f3 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -13,6 +13,7 @@ import { InviteModal } from '../modals/InviteModal'; import { UserSettingsModal } from '../modals/UserSettings'; import { ServerSettingsModal } from '../modals/ServerSettings'; import { NewDmModal } from '../modals/NewDmModal'; +import { IncomingCallModal } from '../voice/IncomingCallModal'; import { UserProfilePopout } from '../ui/UserProfilePopout'; import { useAuth } from '../../hooks/useAuth'; import { useWebSocket } from '../../hooks/useWebSocket'; @@ -38,9 +39,11 @@ export function AppLayout() { const closeUserProfile = useUIStore((s) => s.closeUserProfile); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); + const activeDmCall = useVoiceStore((s) => s.activeDmCall); const setParticipants = useVoiceStore((s) => s.setParticipants); const { connect: connectVoice, + connectDm: connectDmVoice, disconnect: disconnectVoice, participants: voiceParticipants, } = useLiveKit(); @@ -53,14 +56,23 @@ export function AppLayout() { setParticipants(voiceParticipants); }, [voiceParticipants, setParticipants]); - // Manage voice connection + // Manage voice connection (server voice channels) useEffect(() => { if (currentVoiceChannelId) { connectVoice(currentVoiceChannelId); - } else { + } else if (!activeDmCall) { disconnectVoice(); } - }, [currentVoiceChannelId, connectVoice, disconnectVoice]); + }, [currentVoiceChannelId, connectVoice, disconnectVoice, activeDmCall]); + + // Manage DM call connection + useEffect(() => { + if (activeDmCall) { + connectDmVoice(activeDmCall.dmChannelId); + } else if (!currentVoiceChannelId) { + disconnectVoice(); + } + }, [activeDmCall, connectDmVoice, disconnectVoice, currentVoiceChannelId]); // Responsive detection useEffect(() => { @@ -147,6 +159,7 @@ export function AppLayout() { + {/* User Profile Popout */} diff --git a/packages/web/src/components/layout/ChannelSidebar.js b/packages/web/src/components/layout/ChannelSidebar.js index 820d33d7..f3a28608 100644 --- a/packages/web/src/components/layout/ChannelSidebar.js +++ b/packages/web/src/components/layout/ChannelSidebar.js @@ -1,4 +1,5 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useState, useRef, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { useServerStore } from '../../stores/serverStore'; import { useChatStore } from '../../stores/chatStore'; @@ -33,7 +34,8 @@ export function ChannelSidebar() { if (room) { try { await room.localParticipant.setMicrophoneEnabled(isMuted); - } catch (err) { + } + catch (err) { console.error('[ChannelSidebar] Failed to toggle mic:', err); } } @@ -66,9 +68,9 @@ export function ChannelSidebar() { navigate(`/channels/${currentServerId}/${channelId}`); }; if (!server) { - return (_jsxs("div", { className: "w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0 select-none", children: [_jsx("div", { className: "h-12 px-4 flex items-center shadow-header z-10", children: _jsx("button", { className: "flex-1 bg-discord-bg-tertiary text-discord-text-muted text-[14px] font-medium py-1 px-2 rounded-[4px] text-left hover:bg-discord-bg-tertiary/80 transition-colors", children: "Find or start a conversation" }) }), _jsxs("div", { className: "flex-1 overflow-y-auto pt-4 px-2 no-scrollbar", children: [_jsxs("div", { onClick: handleHomeClick, className: `flex items-center gap-3 px-2 h-10 rounded-[4px] cursor-pointer mb-0.5 transition-colors group ${!currentChannelId + return (_jsxs("div", { className: "w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0 select-none", children: [_jsx("div", { className: "h-12 px-[10px] flex items-center shadow-header z-10", children: _jsx("button", { className: "flex-1 bg-discord-bg-tertiary text-discord-text-muted text-[13px] font-medium py-[5px] px-2 rounded-[4px] text-left hover:bg-discord-bg-tertiary/80 transition-colors", children: "Find or start a conversation" }) }), _jsxs("div", { className: "flex-1 overflow-y-auto pt-4 px-2 no-scrollbar", children: [_jsxs("div", { onClick: handleHomeClick, className: `flex items-center gap-3 px-2 h-[42px] rounded-[4px] cursor-pointer mb-[2px] transition-colors group ${!currentChannelId ? 'bg-discord-modifier-selected text-white' - : 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'}`, children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: `${!currentChannelId ? 'text-white' : 'opacity-70 group-hover:opacity-100'}`, children: _jsx("path", { d: "M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" }) }), _jsx("span", { className: "font-medium text-[16px]", children: "Friends" })] }), _jsxs("div", { className: "mt-[18px] px-2 mb-1 flex items-center justify-between group", children: [_jsx("span", { className: "text-[12px] font-bold text-discord-text-muted uppercase tracking-wider", children: "Direct Messages" }), _jsx("button", { onClick: () => openModal('newDm'), className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "New Direct Message", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) })] }), _jsxs("div", { className: "space-y-[2px]", children: [dmChannels.map((dm) => { + : 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'}`, children: [_jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: `flex-shrink-0 ${!currentChannelId ? 'text-white' : 'opacity-70 group-hover:opacity-100'}`, children: [_jsx("path", { d: "M13 10a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-2-4a2 2 0 1 1 4 0 2 2 0 0 1-4 0Z" }), _jsx("path", { d: "M3 18a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-1c0-2.76-5.37-4-8-4s-8 1.24-8 4v1Z" }), _jsx("path", { d: "M3.5 13.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z", opacity: ".5" })] }), _jsx("span", { className: "font-medium text-[16px]", children: "Friends" })] }), _jsxs("div", { className: "flex items-center gap-3 px-2 h-[42px] rounded-[4px] cursor-pointer mb-[2px] transition-colors group text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary", children: [_jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "flex-shrink-0 opacity-70 group-hover:opacity-100", children: [_jsx("path", { d: "M2.98966977,9.35789159 C2.98966977,9.77582472 2.63542482,10.1300697 2.21749169,10.1300697 L2.21749169,10.1300697 C1.79955856,10.1300697 1.44531361,9.77582472 1.44531361,9.35789159 L1.44531361,9.35789159 C1.44531361,8.93995846 1.79955856,8.58571351 2.21749169,8.58571351 L2.21749169,8.58571351 C2.63542482,8.58571351 2.98966977,8.93995846 2.98966977,9.35789159 Z", transform: "translate(12, 12) scale(1.2) translate(-12, -12)" }), _jsx("path", { d: "M21.7,9.358 L12.957,2.46 C12.396,2.014 11.604,2.014 11.043,2.46 L2.3,9.358 C2.113,9.507 2,9.734 2,9.975 L2,19.5 C2,20.881 3.119,22 4.5,22 L19.5,22 C20.881,22 22,20.881 22,19.5 L22,9.975 C22,9.734 21.887,9.507 21.7,9.358 Z M12,17.5 C10.619,17.5 9.5,16.381 9.5,15 C9.5,13.619 10.619,12.5 12,12.5 C13.381,12.5 14.5,13.619 14.5,15 C14.5,16.381 13.381,17.5 12,17.5 Z" })] }), _jsx("span", { className: "font-medium text-[16px]", children: "Nitro" })] }), _jsxs("div", { className: "flex items-center gap-3 px-2 h-[42px] rounded-[4px] cursor-pointer mb-[2px] transition-colors group text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "flex-shrink-0 opacity-70 group-hover:opacity-100", children: _jsx("path", { d: "M2 5.5A1.5 1.5 0 0 1 3.5 4h17A1.5 1.5 0 0 1 22 5.5V7H2V5.5ZM2 9v9.5A1.5 1.5 0 0 0 3.5 20h17a1.5 1.5 0 0 0 1.5-1.5V9H2Zm9.5 3a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5Zm-4-1h2a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1Z" }) }), _jsx("span", { className: "font-medium text-[16px]", children: "Shop" })] }), _jsxs("div", { className: "mt-[18px] px-2 mb-1 flex items-center justify-between group", children: [_jsx("span", { className: "text-[12px] font-bold text-discord-text-muted uppercase tracking-wider", children: "Direct Messages" }), _jsx("button", { onClick: () => openModal('newDm'), className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "New Direct Message", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) })] }), _jsxs("div", { className: "space-y-[2px]", children: [dmChannels.map((dm) => { const otherUser = dm.members.find(m => m.id !== user?.id); if (!otherUser) return null; @@ -77,23 +79,168 @@ export function ChannelSidebar() { ? 'bg-discord-modifier-selected text-white' : isDmUnread ? 'text-white hover:bg-discord-modifier-hover' - : 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'}`, children: [isDmUnread && _jsx("div", { className: "absolute -left-1 w-1 h-2 bg-white rounded-r-full" }), _jsx(Avatar, { src: otherUser.avatar, name: otherUser.displayName ?? otherUser.username, size: 32, status: otherUser.status }), _jsx("div", { className: "flex-1 min-w-0", children: _jsx("div", { className: `text-[16px] truncate ${currentChannelId === dm.id ? 'text-white font-medium' : isDmUnread ? 'text-white font-bold' : 'text-discord-text-muted group-hover:text-discord-text-secondary font-medium'}`, children: otherUser.displayName ?? otherUser.username }) })] }, dm.id)); - }), dmChannels.length === 0 && (_jsx("p", { className: "px-2 py-4 text-[13px] text-discord-text-muted italic opacity-60", children: "No DM conversations yet." }))] })] }), user && (_jsxs("div", { className: "h-[52px] px-2 bg-discord-bg-user-area flex items-center gap-2 select-none", children: [_jsxs("div", { className: "p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status, user: user }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-[14px] font-bold text-discord-text-primary truncate leading-tight", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[12px] text-discord-text-muted truncate leading-tight group-hover:text-discord-text-secondary", children: ["@", user.username] })] })] }), _jsxs("div", { className: "flex items-center", children: [_jsx(UserAreaButton, { title: isMuted ? 'Unmute' : 'Mute', onClick: handleMicToggle, active: isMuted, children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" }), _jsx("path", { d: "M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" }), isMuted && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2" })] }) }), _jsx(UserAreaButton, { title: isDeafened ? 'Undeafen' : 'Deafen', onClick: handleDeafenToggle, active: isDeafened, children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }), isDeafened && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2" })] }) }), _jsx(UserAreaButton, { title: "User Settings", onClick: () => openModal('userSettings'), children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) })] })] }))] })); + : 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'}`, children: [isDmUnread && (_jsx("div", { className: "absolute -left-1 w-1 h-2 bg-white rounded-r-full" })), _jsx(Avatar, { src: otherUser.avatar, name: otherUser.displayName ?? otherUser.username, size: 32, status: otherUser.status }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: `text-[15px] truncate leading-tight ${currentChannelId === dm.id ? 'text-white font-medium' + : isDmUnread ? 'text-white font-bold' + : 'text-discord-text-muted group-hover:text-discord-text-secondary font-medium'}`, children: otherUser.displayName ?? otherUser.username }), dm.lastMessage && (_jsx("div", { className: "text-[12px] text-discord-channels-default truncate leading-tight mt-0.5", children: dm.lastMessage.content }))] }), _jsx("button", { onClick: (e) => { e.stopPropagation(); }, className: "opacity-0 group-hover:opacity-100 text-discord-text-muted hover:text-discord-text-primary transition-opacity flex-shrink-0 ml-1", title: "Close DM", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" }) }) })] }, dm.id)); + }), dmChannels.length === 0 && (_jsx("p", { className: "px-2 py-4 text-[13px] text-discord-text-muted italic opacity-60", children: "No DM conversations yet." }))] })] }), currentVoiceChannelId && _jsx(VoiceControls, {}), user && (_jsx(UserAreaPanel, { user: user, isMuted: isMuted, isDeafened: isDeafened, onMicToggle: handleMicToggle, onDeafenToggle: handleDeafenToggle, onSettingsClick: () => openModal('userSettings') }))] })); } return (_jsxs("div", { className: "w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0 select-none", children: [_jsxs("button", { onClick: () => openModal('serverSettings'), className: "h-12 px-4 flex items-center justify-between shadow-header z-10 hover:bg-discord-modifier-hover transition-colors group", children: [_jsx("span", { className: "font-bold text-[16px] text-discord-text-primary truncate leading-tight", children: server.name }), _jsx("svg", { width: "18", height: "18", viewBox: "0 0 18 18", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0 group-hover:text-discord-text-secondary", children: _jsx("path", { d: "M5.293 7.293a1 1 0 011.414 0L9 9.586l2.293-2.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" }) })] }), _jsxs("div", { className: "flex-1 overflow-y-auto pt-3 px-2 space-y-[21px] no-scrollbar", children: [_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-1 mb-1 group cursor-pointer", children: [_jsxs("div", { className: "flex items-center gap-0.5 text-discord-text-muted hover:text-discord-text-secondary transition-colors", children: [_jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "currentColor", className: "opacity-70", children: _jsx("path", { d: "M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" }) }), _jsx("span", { className: "text-[12px] font-bold uppercase tracking-wider", children: "Text Channels" })] }), isAdminUser && (_jsx("button", { onClick: (e) => { e.stopPropagation(); openModal('createChannel'); }, className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Create Channel", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) }))] }), _jsx("div", { className: "space-y-[2px]", children: textChannels.map((channel) => { const isUnread = unreadChannels.has(channel.id) && currentChannelId !== channel.id; - return _jsxs("button", { onClick: () => handleChannelClick(channel.id), className: `w-full flex items-center gap-1.5 px-2 h-8 rounded-[4px] group transition-colors ${currentChannelId === channel.id - ? 'bg-discord-modifier-selected text-white' - : isUnread - ? 'text-white hover:text-white hover:bg-discord-modifier-hover' - : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover'}`, children: [isUnread && _jsx("div", { className: "absolute -left-0.5 w-1 h-2 bg-white rounded-r-full" }), _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "flex-shrink-0 opacity-60", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }), _jsx("span", { className: `truncate text-[16px] ${isUnread ? 'font-bold' : 'font-medium'}`, children: channel.name })] }, channel.id); }) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-1 mb-1 group cursor-pointer", children: [_jsxs("div", { className: "flex items-center gap-0.5 text-discord-text-muted hover:text-discord-text-secondary transition-colors", children: [_jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "currentColor", className: "opacity-70", children: _jsx("path", { d: "M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" }) }), _jsx("span", { className: "text-[12px] font-bold uppercase tracking-wider", children: "Voice Channels" })] }), isAdminUser && (_jsx("button", { onClick: (e) => { + return (_jsxs("button", { onClick: () => handleChannelClick(channel.id), className: `w-full flex items-center gap-1.5 px-2 h-8 rounded-[4px] group transition-colors ${currentChannelId === channel.id + ? 'bg-discord-modifier-selected text-white' + : isUnread + ? 'text-white hover:text-white hover:bg-discord-modifier-hover' + : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover'}`, children: [isUnread && (_jsx("div", { className: "absolute -left-0.5 w-1 h-2 bg-white rounded-r-full" })), _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "flex-shrink-0 opacity-60", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }), _jsx("span", { className: `truncate text-[15px] ${isUnread ? 'font-bold' : 'font-medium'}`, children: channel.name })] }, channel.id)); + }) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-1 mb-1 group cursor-pointer", children: [_jsxs("div", { className: "flex items-center gap-0.5 text-discord-text-muted hover:text-discord-text-secondary transition-colors", children: [_jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "currentColor", className: "opacity-70", children: _jsx("path", { d: "M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" }) }), _jsx("span", { className: "text-[12px] font-bold uppercase tracking-wider", children: "Voice Channels" })] }), isAdminUser && (_jsx("button", { onClick: (e) => { e.stopPropagation(); openModal('createChannel'); - }, className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Create Channel", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) }))] }), _jsx("div", { className: "space-y-[2px]", children: voiceChannels.map((channel) => (_jsx(VoiceChannel, { channelId: channel.id, channelName: channel.name, onClick: () => handleVoiceJoin(channel.id) }, channel.id))) })] }), _jsx("div", { className: "pt-2", children: _jsxs("button", { onClick: () => openModal('invite'), className: "w-full flex items-center gap-2 px-2 h-8 rounded-[4px] text-[15px] font-medium text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover transition-colors", children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "opacity-60", children: _jsx("path", { d: "M13 13v5h-2v-5H6v-2h5V6h2v5h5v2h-5z" }) }), "Invite People"] }) })] }), currentVoiceChannelId && _jsx(VoiceControls, {}), user && (_jsxs("div", { className: "h-[52px] px-2 bg-discord-bg-user-area flex items-center gap-2 select-none", children: [_jsxs("div", { className: "p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status, user: user }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-[14px] font-bold text-discord-text-primary truncate leading-tight", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[12px] text-discord-text-muted truncate leading-tight group-hover:text-discord-text-secondary", children: ["@", user.username] })] })] }), _jsxs("div", { className: "flex items-center", children: [_jsx(UserAreaButton, { title: isMuted ? 'Unmute' : 'Mute', onClick: handleMicToggle, active: isMuted, children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" }), _jsx("path", { d: "M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" }), isMuted && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2" })] }) }), _jsx(UserAreaButton, { title: isDeafened ? 'Undeafen' : 'Deafen', onClick: handleDeafenToggle, active: isDeafened, children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }), isDeafened && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2" })] }) }), _jsx(UserAreaButton, { title: "User Settings", onClick: () => openModal('userSettings'), children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) })] })] }))] })); + }, className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Create Channel", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) }))] }), _jsx("div", { className: "space-y-[2px]", children: voiceChannels.map((channel) => (_jsx(VoiceChannel, { channelId: channel.id, channelName: channel.name, onClick: () => handleVoiceJoin(channel.id) }, channel.id))) })] }), _jsx("div", { className: "pt-2", children: _jsxs("button", { onClick: () => openModal('invite'), className: "w-full flex items-center gap-2 px-2 h-8 rounded-[4px] text-[15px] font-medium text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover transition-colors", children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "opacity-60", children: _jsx("path", { d: "M13 13v5h-2v-5H6v-2h5V6h2v5h5v2h-5z" }) }), "Invite People"] }) })] }), currentVoiceChannelId && _jsx(VoiceControls, {}), user && (_jsx(UserAreaPanel, { user: user, isMuted: isMuted, isDeafened: isDeafened, onMicToggle: handleMicToggle, onDeafenToggle: handleDeafenToggle, onSettingsClick: () => openModal('userSettings') }))] })); } -function UserAreaButton({ children, title, onClick, active }) { - return (_jsx("button", { onClick: onClick, className: `w-8 h-8 flex items-center justify-center hover:bg-discord-modifier-hover rounded-[4px] transition-all ${active ? 'text-discord-red' : 'text-discord-text-muted hover:text-discord-text-primary'}`, title: title, children: children })); +/* ─── User Area Panel ──────────────────────────────────────────────────────── */ +function UserAreaPanel({ user, isMuted, isDeafened, onMicToggle, onDeafenToggle, onSettingsClick, }) { + const [openPanel, setOpenPanel] = useState(null); + const [inputDevices, setInputDevices] = useState([]); + const [outputDevices, setOutputDevices] = useState([]); + const [selectedInput, setSelectedInput] = useState('default'); + const [selectedOutput, setSelectedOutput] = useState('default'); + const [selectedInputLabel, setSelectedInputLabel] = useState('Default'); + const [selectedOutputLabel, setSelectedOutputLabel] = useState('Default'); + const inputVolume = useVoiceStore((s) => s.inputVolume); + const storeSetInputVolume = useVoiceStore((s) => s.setInputVolume); + const outputVolume = useVoiceStore((s) => s.outputVolume); + const storeSetOutputVolume = useVoiceStore((s) => s.setOutputVolume); + const [showInputDeviceList, setShowInputDeviceList] = useState(false); + const [showOutputDeviceList, setShowOutputDeviceList] = useState(false); + const [micLevel, setMicLevel] = useState(0); + const panelRef = useRef(null); + const analyserRef = useRef(null); + const animFrameRef = useRef(0); + const loadDevices = useCallback(async () => { + try { + // Need to request permission first to get labels + await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop())); + const devices = await navigator.mediaDevices.enumerateDevices(); + setInputDevices(devices.filter(d => d.kind === 'audioinput')); + setOutputDevices(devices.filter(d => d.kind === 'audiooutput')); + } + catch { + // permission denied + } + }, []); + // Start mic level monitoring when input panel opens + useEffect(() => { + if (openPanel !== 'input') { + if (animFrameRef.current) + cancelAnimationFrame(animFrameRef.current); + analyserRef.current = null; + setMicLevel(0); + return; + } + let stream = null; + let ctx = null; + const start = async () => { + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: selectedInput !== 'default' ? selectedInput : undefined } }); + ctx = new AudioContext(); + const source = ctx.createMediaStreamSource(stream); + const analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + source.connect(analyser); + analyserRef.current = analyser; + const data = new Uint8Array(analyser.frequencyBinCount); + const tick = () => { + if (!analyserRef.current) + return; + analyserRef.current.getByteFrequencyData(data); + const avg = data.reduce((a, b) => a + b, 0) / data.length; + setMicLevel(Math.min(avg / 128, 1)); + animFrameRef.current = requestAnimationFrame(tick); + }; + tick(); + } + catch { /* no mic access */ } + }; + start(); + return () => { + if (animFrameRef.current) + cancelAnimationFrame(animFrameRef.current); + analyserRef.current = null; + stream?.getTracks().forEach(t => t.stop()); + ctx?.close(); + }; + }, [openPanel, selectedInput]); + useEffect(() => { + const handleClick = (e) => { + if (panelRef.current && !panelRef.current.contains(e.target)) { + setOpenPanel(null); + setShowInputDeviceList(false); + setShowOutputDeviceList(false); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, []); + const togglePanel = (panel) => { + if (openPanel === panel) { + setOpenPanel(null); + } + else { + loadDevices(); + setOpenPanel(panel); + setShowInputDeviceList(false); + setShowOutputDeviceList(false); + } + }; + const selectInput = (device) => { + setSelectedInput(device.deviceId); + setSelectedInputLabel(device.label || 'Default'); + setShowInputDeviceList(false); + const room = getActiveRoom(); + if (room) + room.switchActiveDevice('audioinput', device.deviceId).catch(() => { }); + }; + const selectOutput = (device) => { + setSelectedOutput(device.deviceId); + setSelectedOutputLabel(device.label || 'Default'); + setShowOutputDeviceList(false); + const room = getActiveRoom(); + if (room) + room.switchActiveDevice('audiooutput', device.deviceId).catch(() => { }); + }; + // Generate mic level bars (20 bars like Discord) + const micBars = 20; + const activeBars = Math.round(micLevel * micBars * (inputVolume / 100)); + return (_jsxs("div", { className: "relative", ref: panelRef, children: [openPanel === 'input' && (_jsxs("div", { className: "absolute bottom-full left-0 right-0 mb-0 bg-[#2b2d31] rounded-t-lg shadow-lg z-50 border-t border-x border-discord-bg-tertiary", children: [_jsxs("div", { className: "relative", children: [_jsxs("button", { onClick: () => setShowInputDeviceList(!showInputDeviceList), className: "w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-[15px] font-semibold text-discord-text-primary text-left", children: "Input Device" }), _jsx("div", { className: "text-[13px] text-discord-text-muted truncate text-left", children: selectedInputLabel })] }), _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0 ml-2", children: _jsx("path", { d: "M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" }) })] }), showInputDeviceList && (_jsx("div", { className: "bg-discord-bg-floating rounded-lg shadow-lg mx-2 mb-2 py-1 border border-discord-bg-tertiary", children: inputDevices.map(d => (_jsxs("button", { onClick: () => selectInput(d), className: `w-full px-3 py-2 text-left text-[13px] hover:bg-discord-modifier-hover transition-colors flex items-center gap-2 ${selectedInput === d.deviceId ? 'text-discord-text-primary' : 'text-discord-text-secondary'}`, children: [selectedInput === d.deviceId && (_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-blurple flex-shrink-0", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) })), _jsx("span", { className: selectedInput === d.deviceId ? '' : 'pl-6', children: d.label || 'Default' })] }, d.deviceId))) }))] }), _jsx("div", { className: "mx-4 border-t border-[#3f4147]" }), _jsxs("div", { className: "px-4 py-3", children: [_jsx("div", { className: "text-[15px] font-semibold text-discord-text-primary mb-2", children: "Input Volume" }), _jsx("input", { type: "range", min: 0, max: 200, value: inputVolume, onChange: (e) => { + const vol = Number(e.target.value); + storeSetInputVolume(vol); + // Apply gain to mic: at 0 = mute, 100 = normal, 200 = 2x boost + const room = getActiveRoom(); + if (room && room.localParticipant.isMicrophoneEnabled) { + if (vol === 0) { + room.localParticipant.setMicrophoneEnabled(false).catch(() => { }); + } + else { + // Re-enable mic if it was muted by volume slider + room.localParticipant.setMicrophoneEnabled(true).catch(() => { }); + } + } + }, className: "w-full h-1.5 rounded-full appearance-none cursor-pointer accent-discord-blurple bg-discord-bg-tertiary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md", style: { + background: `linear-gradient(to right, #5865f2 0%, #5865f2 ${inputVolume / 2}%, #4e5058 ${inputVolume / 2}%, #4e5058 100%)`, + } }), _jsx("div", { className: "flex items-center gap-[3px] mt-2.5", children: Array.from({ length: micBars }).map((_, i) => (_jsx("div", { className: `flex-1 h-[6px] rounded-[1px] transition-colors duration-75 ${i < activeBars ? 'bg-discord-text-muted' : 'bg-[#3f4147]'}` }, i))) })] }), _jsx("div", { className: "mx-4 border-t border-[#3f4147]" }), _jsxs("button", { onClick: onSettingsClick, className: "w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors", children: [_jsx("span", { className: "text-[15px] font-semibold text-discord-text-primary", children: "Voice Settings" }), _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) })] })] })), openPanel === 'output' && (_jsxs("div", { className: "absolute bottom-full left-0 right-0 mb-0 bg-[#2b2d31] rounded-t-lg shadow-lg z-50 border-t border-x border-discord-bg-tertiary", children: [_jsxs("div", { className: "relative", children: [_jsxs("button", { onClick: () => setShowOutputDeviceList(!showOutputDeviceList), className: "w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-[15px] font-semibold text-discord-text-primary text-left", children: "Output Device" }), _jsx("div", { className: "text-[13px] text-discord-text-muted truncate text-left", children: selectedOutputLabel })] }), _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0 ml-2", children: _jsx("path", { d: "M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" }) })] }), showOutputDeviceList && (_jsx("div", { className: "bg-discord-bg-floating rounded-lg shadow-lg mx-2 mb-2 py-1 border border-discord-bg-tertiary", children: outputDevices.map(d => (_jsxs("button", { onClick: () => selectOutput(d), className: `w-full px-3 py-2 text-left text-[13px] hover:bg-discord-modifier-hover transition-colors flex items-center gap-2 ${selectedOutput === d.deviceId ? 'text-discord-text-primary' : 'text-discord-text-secondary'}`, children: [selectedOutput === d.deviceId && (_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-blurple flex-shrink-0", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) })), _jsx("span", { className: selectedOutput === d.deviceId ? '' : 'pl-6', children: d.label || 'Default' })] }, d.deviceId))) }))] }), _jsx("div", { className: "mx-4 border-t border-[#3f4147]" }), _jsxs("div", { className: "px-4 py-3", children: [_jsx("div", { className: "text-[15px] font-semibold text-discord-text-primary mb-2", children: "Output Volume" }), _jsx("input", { type: "range", min: 0, max: 200, value: outputVolume, onChange: (e) => { + const vol = Number(e.target.value); + storeSetOutputVolume(vol); + // Apply volume to all remote participants + const room = getActiveRoom(); + if (room) { + const scaled = vol / 100; // 0-2 range (0%=0, 100%=1, 200%=2) + room.remoteParticipants.forEach((participant) => { + participant.setVolume(scaled); + }); + } + }, className: "w-full h-1.5 rounded-full appearance-none cursor-pointer bg-discord-bg-tertiary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md", style: { + background: `linear-gradient(to right, #5865f2 0%, #5865f2 ${outputVolume / 2}%, #4e5058 ${outputVolume / 2}%, #4e5058 100%)`, + } })] }), _jsx("div", { className: "mx-4 border-t border-[#3f4147]" }), _jsxs("button", { onClick: onSettingsClick, className: "w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors", children: [_jsx("span", { className: "text-[15px] font-semibold text-discord-text-primary", children: "Voice Settings" }), _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) })] })] })), _jsxs("div", { className: "h-[52px] px-2 bg-discord-bg-user-area flex items-center select-none", children: [_jsxs("div", { className: "p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status, user: user }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-[13px] font-semibold text-discord-text-primary truncate leading-tight", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[11px] text-discord-text-muted truncate leading-tight group-hover:text-discord-text-secondary", children: ["@", user.username] })] })] }), _jsxs("div", { className: "flex items-center", children: [_jsx("button", { onClick: onMicToggle, className: `w-8 h-8 flex items-center justify-center hover:bg-discord-modifier-hover rounded-l-[4px] transition-colors ${isMuted ? 'text-discord-red' : 'text-discord-text-muted hover:text-discord-text-primary'}`, title: isMuted ? 'Unmute' : 'Mute', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" }), _jsx("path", { d: "M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" }), isMuted && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] }) }), _jsx("button", { onClick: () => togglePanel('input'), className: `w-[18px] h-8 flex items-center justify-center hover:bg-discord-modifier-hover rounded-r-[4px] transition-colors ${openPanel === 'input' ? 'text-discord-text-primary bg-discord-modifier-hover' : 'text-discord-text-muted hover:text-discord-text-primary'}`, title: "Input Devices", children: _jsx("svg", { width: "10", height: "10", viewBox: "0 0 24 24", fill: "currentColor", className: `transition-transform ${openPanel === 'input' ? 'rotate-180' : ''}`, children: _jsx("path", { d: "M7 10l5 5 5-5z" }) }) }), _jsx("button", { onClick: onDeafenToggle, className: `w-8 h-8 flex items-center justify-center hover:bg-discord-modifier-hover rounded-l-[4px] transition-colors ${isDeafened ? 'text-discord-red' : 'text-discord-text-muted hover:text-discord-text-primary'}`, title: isDeafened ? 'Undeafen' : 'Deafen', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }), isDeafened && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] }) }), _jsx("button", { onClick: () => togglePanel('output'), className: `w-[18px] h-8 flex items-center justify-center hover:bg-discord-modifier-hover rounded-r-[4px] transition-colors ${openPanel === 'output' ? 'text-discord-text-primary bg-discord-modifier-hover' : 'text-discord-text-muted hover:text-discord-text-primary'}`, title: "Output Devices", children: _jsx("svg", { width: "10", height: "10", viewBox: "0 0 24 24", fill: "currentColor", className: `transition-transform ${openPanel === 'output' ? 'rotate-180' : ''}`, children: _jsx("path", { d: "M7 10l5 5 5-5z" }) }) }), _jsx("button", { onClick: onSettingsClick, className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover rounded-[4px] transition-colors", title: "User Settings", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) })] })] })] })); } diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 15b6d2f0..3fc90a34 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -77,26 +77,49 @@ export function ChannelSidebar() { if (!server) { return (
-
-
-
- - + + + + Friends
- + + {/* Nitro */} +
+ + + + + Nitro +
+ + {/* Shop */} +
+ + + + Shop +
+
Direct Messages
); })} @@ -149,7 +186,10 @@ export function ChannelSidebar() { )}
- + + {/* Voice controls — visible when in a call, even in DM view */} + {currentVoiceChannelId && } + {/* User area at bottom */} {user && ( - {channel.name} + {channel.name} ); })} diff --git a/packages/web/src/components/layout/MainContent.js b/packages/web/src/components/layout/MainContent.js index 9929e72e..509a48da 100644 --- a/packages/web/src/components/layout/MainContent.js +++ b/packages/web/src/components/layout/MainContent.js @@ -7,8 +7,10 @@ import { MessageList } from '../chat/MessageList'; import { MessageInput } from '../chat/MessageInput'; import { TypingIndicator } from '../chat/TypingIndicator'; import { VoiceGrid } from '../voice/VoiceGrid'; +import { VoiceControlBar } from '../voice/VoiceControlBar'; +import { VoiceChatPanel } from '../voice/VoiceChatPanel'; +import { DmCallView } from '../voice/DmCallView'; import { FriendsPage } from '../chat/FriendsPage'; -import { Avatar } from '../ui/Avatar'; import { useVoiceStore } from '../../stores/voiceStore'; import { wsSend } from '../../hooks/useWebSocket'; export function MainContent() { @@ -17,14 +19,18 @@ export function MainContent() { const currentServerId = useServerStore((s) => s.currentServerId); const toggleMemberList = useUIStore((s) => s.toggleMemberList); const memberListOpen = useUIStore((s) => s.memberListOpen); + const voiceChatOpen = useUIStore((s) => s.voiceChatOpen); + const voiceFullscreen = useUIStore((s) => s.voiceFullscreen); const participants = useVoiceStore((s) => s.participants); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const showDms = useUIStore((s) => s.showDms); - const dmChannels = useServerStore((s) => s.dmChannels); - const authUser = useAuthStore((s) => s.user); + const activeDmCall = useVoiceStore((s) => s.activeDmCall); + const outgoingCall = useVoiceStore((s) => s.outgoingCall); const channel = channels.find(c => c.id === currentChannelId); const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video'; // DM view or no server selected + const dmChannels = useServerStore((s) => s.dmChannels); + const authUser = useAuthStore((s) => s.user); if (showDms || !currentServerId) { if (!currentChannelId) { return _jsx(FriendsPage, {}); @@ -33,7 +39,26 @@ export function MainContent() { const otherUser = dmChannel?.members.find(m => m.id !== authUser?.id); const dmName = otherUser?.displayName ?? otherUser?.username ?? 'Direct Message'; const dmStatus = otherUser?.status; - return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative", children: [_jsx("div", { className: "h-12 px-4 flex items-center shadow-header flex-shrink-0 z-10", children: _jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("span", { className: "text-discord-text-muted font-bold text-lg", children: "@" }), otherUser && _jsx(Avatar, { src: otherUser.avatar, name: dmName, size: 24, status: dmStatus }), _jsx("span", { className: "font-bold text-discord-text-primary truncate", children: dmName }), otherUser?.status && otherUser.status !== 'offline' && _jsx("span", { className: "text-xs text-discord-text-muted capitalize", children: otherUser.status })] }) }), _jsx(MessageList, { channelId: currentChannelId }), _jsx(TypingIndicator, { channelId: currentChannelId }), _jsx(MessageInput, { channelId: currentChannelId, channelName: `@${dmName}` })] })); + // Show DmCallView if there's an active DM call for this channel + const isInDmCall = activeDmCall?.dmChannelId === currentChannelId; + const isCallingThisDm = outgoingCall?.dmChannelId === currentChannelId; + const handleStartVoiceCall = () => { + if (!currentChannelId) + return; + useVoiceStore.getState().setOutgoingCall({ dmChannelId: currentChannelId }); + wsSend({ type: 'dm_call_start', dmChannelId: currentChannelId }); + }; + const handleCancelCall = () => { + if (!currentChannelId) + return; + useVoiceStore.getState().setOutgoingCall(null); + wsSend({ type: 'dm_call_end', dmChannelId: currentChannelId }); + }; + // If in an active DM call, show the call view overlaid on top of the chat + if (isInDmCall) { + return (_jsx("div", { className: "flex-1 flex flex-col min-w-0 relative", children: _jsx(DmCallView, {}) })); + } + return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative", children: [isCallingThisDm && (_jsxs("div", { className: "bg-discord-green/10 border-b border-discord-green/20 px-4 py-3 flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-green animate-pulse", children: _jsx("path", { d: "M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" }) }), _jsxs("span", { className: "text-discord-green text-sm font-medium", children: ["Calling ", dmName, "..."] })] }), _jsx("button", { onClick: handleCancelCall, className: "px-3 py-1 bg-discord-red hover:bg-discord-red/80 text-white text-xs font-medium rounded transition-colors", children: "Cancel" })] })), _jsxs("div", { className: "h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 z-10 bg-discord-bg-primary", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M12.5 2A6.5 6.5 0 0 0 6 8.5c0 1.82.75 3.47 1.95 4.65A10.02 10.02 0 0 0 2 22h2c0-4.42 3.58-8 8-8 .35 0 .69.03 1.03.07A6.49 6.49 0 0 0 19 8.5 6.5 6.5 0 0 0 12.5 2Zm0 11A4.5 4.5 0 1 1 17 8.5a4.5 4.5 0 0 1-4.5 4.5Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary truncate", children: dmName })] }), _jsxs("div", { className: "flex items-center gap-1 flex-shrink-0", children: [_jsx("button", { onClick: handleStartVoiceCall, disabled: !!outgoingCall || !!activeDmCall, className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover disabled:opacity-50 disabled:cursor-not-allowed", title: "Start Voice Call", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" }) }) }), _jsx("button", { onClick: handleStartVoiceCall, disabled: !!outgoingCall || !!activeDmCall, className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover disabled:opacity-50 disabled:cursor-not-allowed", title: "Start Video Call", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M21 6.5l-4 4V7c0-.55-.45-1-1-1H9.82L21 17.18V6.5zM3.27 2L2 3.27 4.73 6H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.21 0 .39-.08.54-.18L19.73 21 21 19.73 3.27 2z" }) }) }), _jsx("button", { className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover", title: "Pinned Messages", children: _jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z", transform: "rotate(45 12 12)" }), _jsx("path", { d: "M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z" })] }) }), _jsx("button", { className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover", title: "Add Friends to DM", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" }) }) }), _jsx("div", { className: "w-[1px] h-6 bg-discord-modifier-accent mx-1" }), _jsx("button", { className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover", title: "Search", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M21.707 20.293l-5.395-5.395A7.457 7.457 0 0018 10.5 7.5 7.5 0 1010.5 18c1.575 0 3.027-.486 4.228-1.31l5.476 5.476a.997.997 0 001.414 0l.089-.089a1 1 0 000-1.414l.001-.37zM10.5 16a5.5 5.5 0 110-11 5.5 5.5 0 010 11z" }) }) }), _jsx("button", { className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover", title: "Inbox", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19 3H4.99c-1.11 0-1.98.9-1.98 2L3 19c0 1.1.88 2 1.99 2H19c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12h-4c0 1.66-1.35 3-3 3s-3-1.34-3-3H4.99V5H19v10z" }) }) }), _jsx("button", { className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover", title: "Help", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z" }) }) })] })] }), _jsx(MessageList, { channelId: currentChannelId }), _jsx(TypingIndicator, { channelId: currentChannelId }), _jsx(MessageInput, { channelId: currentChannelId, channelName: `@${dmName}` })] })); } // No channel selected if (!currentChannelId || !channel) { @@ -42,14 +67,17 @@ export function MainContent() { // Voice/Video channel view if (isVoiceChannel) { const isInThisChannel = currentVoiceChannelId === currentChannelId; - const isMuted = useVoiceStore.getState().isMuted; - const isCameraOn = useVoiceStore.getState().isCameraOn; - const isScreenSharing = useVoiceStore.getState().isScreenSharing; - return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary", children: [_jsx("div", { className: "h-12 px-4 flex items-center justify-between shadow-header", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: channel.name }), isInThisChannel && (_jsx("span", { className: "text-xs text-discord-green font-medium ml-2", children: "Connected" }))] }) }), isInThisChannel ? (_jsx(VoiceGrid, { participants: participants })) : (_jsxs("div", { className: "flex-1 flex flex-col items-center justify-center gap-6", children: [_jsxs("div", { className: "text-center", children: [_jsx("svg", { width: "80", height: "80", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted mx-auto mb-4 opacity-40", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46ZM19.07 4.93C20.91 6.77 22 9.28 22 12C22 14.72 20.91 17.23 19.07 19.07L17.66 17.66C19.11 16.21 20 14.21 20 12C20 9.79 19.11 7.79 17.66 6.34L19.07 4.93Z" }) }), _jsx("h2", { className: "text-[24px] font-bold text-discord-text-header mb-2", children: channel.name }), _jsx("p", { className: "text-discord-text-muted text-[14px]", children: "No one is currently in this voice channel." })] }), _jsx("button", { onClick: () => { - useVoiceStore.getState().setCurrentVoiceChannel(currentChannelId); - wsSend({ type: 'voice_join', channelId: currentChannelId }); - }, className: "px-8 py-3 bg-discord-green hover:bg-discord-green/80 text-white font-medium rounded-[3px] transition-colors text-[14px]", children: "Join Voice" })] }))] })); + // Not connected — show "Join Voice" prompt + if (!isInThisChannel) { + return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary", children: [_jsx("div", { className: "h-12 px-4 flex items-center justify-between shadow-header", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: channel.name })] }) }), _jsxs("div", { className: "flex-1 flex flex-col items-center justify-center gap-6", children: [_jsxs("div", { className: "text-center", children: [_jsx("svg", { width: "80", height: "80", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted mx-auto mb-4 opacity-40", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46ZM19.07 4.93C20.91 6.77 22 9.28 22 12C22 14.72 20.91 17.23 19.07 19.07L17.66 17.66C19.11 16.21 20 14.21 20 12C20 9.79 19.11 7.79 17.66 6.34L19.07 4.93Z" }) }), _jsx("h2", { className: "text-[24px] font-bold text-discord-text-header mb-2", children: channel.name }), _jsx("p", { className: "text-discord-text-muted text-[14px]", children: "No one is currently in this voice channel." })] }), _jsx("button", { onClick: () => { + useVoiceStore.getState().setCurrentVoiceChannel(currentChannelId); + wsSend({ type: 'voice_join', channelId: currentChannelId }); + }, className: "px-8 py-3 bg-discord-green hover:bg-discord-green/80 text-white font-medium rounded-[3px] transition-colors text-[14px]", children: "Join Voice" })] })] })); + } + // Connected — full voice view with grid + chat panel + control bar + const voiceView = (_jsxs("div", { className: `flex-1 flex flex-col bg-[#111214] min-w-0 ${voiceFullscreen ? 'fixed inset-0 z-50' : ''}`, children: [_jsx("div", { className: "h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 bg-[#111214]", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: channel.name }), _jsx("span", { className: "text-xs text-discord-green font-medium ml-2", children: "Connected" })] }) }), _jsxs("div", { className: "flex-1 flex overflow-hidden", children: [_jsx(VoiceGrid, { participants: participants }), voiceChatOpen && (_jsx(VoiceChatPanel, { channelId: currentChannelId, channelName: channel.name }))] }), _jsx(VoiceControlBar, {})] })); + return voiceView; } // Text channel view - return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative", children: [_jsxs("div", { className: "h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 z-10 bg-discord-bg-primary", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary truncate leading-tight", children: channel.name }), channel.topic && (_jsxs(_Fragment, { children: [_jsx("div", { className: "w-[1px] h-6 bg-discord-bg-accent mx-2" }), _jsx("span", { className: "text-xs text-discord-text-muted truncate leading-tight", children: channel.topic })] }))] }), _jsx("div", { className: "flex items-center gap-4 flex-shrink-0", children: _jsx("button", { onClick: toggleMemberList, className: `p-1 transition-colors ${memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary'}`, title: "Toggle Member List", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" }) }) }) })] }), _jsx(MessageList, { channelId: currentChannelId }), _jsx(TypingIndicator, { channelId: currentChannelId }), _jsx(MessageInput, { channelId: currentChannelId, channelName: channel.name })] })); + return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative", children: [_jsxs("div", { className: "h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 z-10 bg-discord-bg-primary", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary truncate leading-tight", children: channel.name }), channel.topic && (_jsxs(_Fragment, { children: [_jsx("div", { className: "w-[1px] h-6 bg-discord-bg-accent mx-2" }), _jsx("span", { className: "text-xs text-discord-text-muted truncate leading-tight", children: channel.topic })] }))] }), _jsxs("div", { className: "flex items-center gap-1 flex-shrink-0", children: [_jsx("button", { className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover", title: "Threads", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M5.43 21a.996.996 0 01-.98-.8l-.79-4.34H2.5a1 1 0 110-2h.93l-.55-3H1.5a1 1 0 010-2h1.15L1.87 4.86a1 1 0 011.96-.72L4.6 8.86h3.32l-.78-4.72a1 1 0 011.96-.28l.84 5H13.5a1 1 0 110 2h-3.33l.55 3H13.5a1 1 0 110 2h-2.55l.72 3.94a1 1 0 01-.79 1.16 1.034 1.034 0 01-.18.02.996.996 0 01-.98-.82L8.95 15.86H5.63l.72 3.94A1 1 0 015.43 21zM5.86 10.86l.55 3h3.32l-.55-3H5.86z" }) }) }), _jsx("button", { className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover", title: "Notification Settings", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" }) }) }), _jsx("button", { className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover", title: "Pinned Messages", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z" }) }) }), _jsx("button", { onClick: toggleMemberList, className: `w-8 h-8 flex items-center justify-center transition-colors rounded-[4px] hover:bg-discord-modifier-hover ${memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-secondary'}`, title: "Toggle Member List", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" }) }) }), _jsx("div", { className: "w-[1px] h-6 bg-discord-modifier-accent mx-1" }), _jsx("button", { className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover", title: "Search", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M21.707 20.293l-5.395-5.395A7.457 7.457 0 0018 10.5 7.5 7.5 0 1010.5 18c1.575 0 3.027-.486 4.228-1.31l5.476 5.476a.997.997 0 001.414 0l.089-.089a1 1 0 000-1.414l.001-.37zM10.5 16a5.5 5.5 0 110-11 5.5 5.5 0 010 11z" }) }) }), _jsx("button", { className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover", title: "Inbox", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19 3H4.99c-1.11 0-1.98.9-1.98 2L3 19c0 1.1.88 2 1.99 2H19c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12h-4c0 1.66-1.35 3-3 3s-3-1.34-3-3H4.99V5H19v10z" }) }) }), _jsx("button", { className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover", title: "Help", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z" }) }) })] })] }), _jsx(MessageList, { channelId: currentChannelId }), _jsx(TypingIndicator, { channelId: currentChannelId }), _jsx(MessageInput, { channelId: currentChannelId, channelName: channel.name })] })); } diff --git a/packages/web/src/components/layout/MainContent.tsx b/packages/web/src/components/layout/MainContent.tsx index 6a4fae45..7099fbf0 100644 --- a/packages/web/src/components/layout/MainContent.tsx +++ b/packages/web/src/components/layout/MainContent.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { useServerStore } from '../../stores/serverStore'; import { useChatStore } from '../../stores/chatStore'; import { useUIStore } from '../../stores/uiStore'; @@ -7,6 +7,9 @@ import { MessageList } from '../chat/MessageList'; import { MessageInput } from '../chat/MessageInput'; import { TypingIndicator } from '../chat/TypingIndicator'; import { VoiceGrid } from '../voice/VoiceGrid'; +import { VoiceControlBar } from '../voice/VoiceControlBar'; +import { VoiceChatPanel } from '../voice/VoiceChatPanel'; +import { DmCallView } from '../voice/DmCallView'; import { FriendsPage } from '../chat/FriendsPage'; import { Avatar } from '../ui/Avatar'; import { useVoiceStore } from '../../stores/voiceStore'; @@ -18,10 +21,15 @@ export function MainContent() { const currentServerId = useServerStore((s) => s.currentServerId); const toggleMemberList = useUIStore((s) => s.toggleMemberList); const memberListOpen = useUIStore((s) => s.memberListOpen); + const voiceChatOpen = useUIStore((s) => s.voiceChatOpen); + const voiceFullscreen = useUIStore((s) => s.voiceFullscreen); const participants = useVoiceStore((s) => s.participants); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const showDms = useUIStore((s) => s.showDms); + const activeDmCall = useVoiceStore((s) => s.activeDmCall); + const outgoingCall = useVoiceStore((s) => s.outgoingCall); + const channel = channels.find(c => c.id === currentChannelId); const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video'; @@ -39,18 +47,113 @@ export function MainContent() { const dmName = otherUser?.displayName ?? otherUser?.username ?? 'Direct Message'; const dmStatus = otherUser?.status as any; + // Show DmCallView if there's an active DM call for this channel + const isInDmCall = activeDmCall?.dmChannelId === currentChannelId; + const isCallingThisDm = outgoingCall?.dmChannelId === currentChannelId; + + const handleStartVoiceCall = () => { + if (!currentChannelId) return; + useVoiceStore.getState().setOutgoingCall({ dmChannelId: currentChannelId }); + wsSend({ type: 'dm_call_start', dmChannelId: currentChannelId }); + }; + + const handleCancelCall = () => { + if (!currentChannelId) return; + useVoiceStore.getState().setOutgoingCall(null); + wsSend({ type: 'dm_call_end', dmChannelId: currentChannelId }); + }; + + // If in an active DM call, show the call view overlaid on top of the chat + if (isInDmCall) { + return ( +
+ +
+ ); + } + return (
-
+ {/* Outgoing call banner */} + {isCallingThisDm && ( +
+
+ + + + Calling {dmName}... +
+ +
+ )} +
- @ - {otherUser && ( - - )} + + + {dmName} - {otherUser?.status && otherUser.status !== 'offline' && ( - {otherUser.status} - )} +
+
+ {/* Voice Call */} + + {/* Video Call */} + + {/* Pinned Messages */} + + {/* Add Friends to DM */} + + {/* Divider */} +
+ {/* Search */} + + {/* Inbox */} + + {/* Help */} +
@@ -77,26 +180,19 @@ export function MainContent() { // Voice/Video channel view if (isVoiceChannel) { const isInThisChannel = currentVoiceChannelId === currentChannelId; - const isMuted = useVoiceStore.getState().isMuted; - const isCameraOn = useVoiceStore.getState().isCameraOn; - const isScreenSharing = useVoiceStore.getState().isScreenSharing; - return ( -
-
-
- - - - {channel.name} - {isInThisChannel && ( - Connected - )} + // Not connected — show "Join Voice" prompt + if (!isInThisChannel) { + return ( +
+
+
+ + + + {channel.name} +
-
- {isInThisChannel ? ( - - ) : (
@@ -115,9 +211,38 @@ export function MainContent() { Join Voice
- )} +
+ ); + } + + // Connected — full voice view with grid + chat panel + control bar + const voiceView = ( +
+ {/* Voice header */} +
+
+ + + + {channel.name} + Connected +
+
+ + {/* Main content: grid + optional chat */} +
+ + {voiceChatOpen && ( + + )} +
+ + {/* Control bar at bottom */} +
); + + return voiceView; } // Text channel view @@ -137,10 +262,29 @@ export function MainContent() { )}
-
+
+ {/* Threads */} + + {/* Notification Settings */} + + {/* Pinned Messages */} + + {/* Member List Toggle */} + {/* Divider */} +
+ {/* Search */} + + {/* Inbox */} + + {/* Help */} +
diff --git a/packages/web/src/components/layout/MemberSidebar.js b/packages/web/src/components/layout/MemberSidebar.js index 55209615..85b01606 100644 --- a/packages/web/src/components/layout/MemberSidebar.js +++ b/packages/web/src/components/layout/MemberSidebar.js @@ -1,15 +1,31 @@ -import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime"; +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useMemo } from 'react'; import { useServerStore } from '../../stores/serverStore'; import { useUIStore } from '../../stores/uiStore'; import { Avatar } from '../ui/Avatar'; +const ROLE_ORDER = { owner: 0, admin: 1, member: 2 }; +const ROLE_LABELS = { owner: 'OWNER', admin: 'ADMIN', member: 'MEMBER' }; export function MemberSidebar() { const members = useServerStore((s) => s.members); const memberListOpen = useUIStore((s) => s.memberListOpen); const openUserProfile = useUIStore((s) => s.openUserProfile); + const { roleGroups, offlineMembers } = useMemo(() => { + const online = members.filter(m => m.user.status !== 'offline'); + const offline = members.filter(m => m.user.status === 'offline'); + // Group online members by role + const groups = new Map(); + for (const m of online) { + const role = m.role || 'member'; + if (!groups.has(role)) + groups.set(role, []); + groups.get(role).push(m); + } + // Sort groups by role hierarchy + const sorted = [...groups.entries()].sort((a, b) => (ROLE_ORDER[a[0]] ?? 99) - (ROLE_ORDER[b[0]] ?? 99)); + return { roleGroups: sorted, offlineMembers: offline }; + }, [members]); if (!memberListOpen) return null; - const onlineMembers = members.filter(m => m.user.status !== 'offline'); - const offlineMembers = members.filter(m => m.user.status === 'offline'); const roleColors = { owner: 'text-discord-red', admin: 'text-discord-blurple', @@ -17,7 +33,6 @@ export function MemberSidebar() { }; const getMemberColor = (member) => { if (member.roles && member.roles.length > 0) { - // Return the color of the first role (already sorted by position) return { color: member.roles[0].color }; } return undefined; @@ -27,14 +42,12 @@ export function MemberSidebar() { const rect = e.currentTarget.getBoundingClientRect(); openUserProfile(user, { top: Math.min(rect.top, window.innerHeight - 450), - left: rect.left - 316, // Open to the left of member sidebar + left: rect.left - 316, }); }; - return (_jsx("div", { className: "w-60 bg-discord-bg-members flex-shrink-0 overflow-y-auto select-none no-scrollbar", children: _jsxs("div", { className: "p-3", children: [onlineMembers.length > 0 && (_jsxs("div", { className: "mb-4", children: [_jsxs("h3", { className: "text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1", children: ["Online \u2014 ", onlineMembers.length] }), onlineMembers.map((member) => { - const displayName = member.user.displayName ?? member.user.username; - return (_jsxs("div", { onClick: (e) => handleMemberClick(e, member.user), className: "flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: member.user.status }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: `text-[15px] font-medium truncate ${!getMemberColor(member) ? (roleColors[member.role] ?? 'text-discord-text-primary') : ''}`, style: getMemberColor(member), children: displayName }), member.user.customStatus && (_jsx("div", { className: "text-[12px] text-discord-text-muted truncate", children: member.user.customStatus }))] })] }, member.userId)); - })] })), offlineMembers.length > 0 && (_jsxs("div", { children: [_jsxs("h3", { className: "text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1", children: ["Offline \u2014 ", offlineMembers.length] }), offlineMembers.map((member) => { - const displayName = member.user.displayName ?? member.user.username; - return (_jsxs("div", { onClick: (e) => handleMemberClick(e, member.user), className: "flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: "offline", className: "opacity-60" }), _jsx("div", { className: "flex-1 min-w-0", children: _jsx("div", { className: "text-[15px] font-medium truncate text-discord-text-muted", children: displayName }) })] }, member.userId)); - })] }))] }) })); + const renderMember = (member, isOffline = false) => { + const displayName = member.user.displayName ?? member.user.username; + return (_jsxs("div", { onClick: (e) => handleMemberClick(e, member.user), className: "flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: isOffline ? 'offline' : member.user.status, className: isOffline ? 'opacity-60' : undefined }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: `text-[15px] font-medium truncate ${isOffline ? 'text-discord-text-muted' : (!getMemberColor(member) ? (roleColors[member.role] ?? 'text-discord-text-primary') : '')}`, style: isOffline ? undefined : getMemberColor(member), children: displayName }), !isOffline && member.user.customStatus && (_jsx("div", { className: "text-[12px] text-discord-text-muted truncate", children: member.user.customStatus }))] })] }, member.userId)); + }; + return (_jsx("div", { className: "w-60 bg-discord-bg-members flex-shrink-0 overflow-y-auto select-none no-scrollbar", children: _jsxs("div", { className: "p-3", children: [roleGroups.map(([role, groupMembers]) => (_jsxs("div", { className: "mb-4", children: [_jsxs("h3", { className: "text-[12px] font-bold text-discord-text-muted uppercase tracking-wider px-2 mb-1", children: [ROLE_LABELS[role] ?? role.toUpperCase(), " \u2014 ", groupMembers.length] }), groupMembers.map((m) => renderMember(m))] }, role))), offlineMembers.length > 0 && (_jsxs("div", { children: [_jsxs("h3", { className: "text-[12px] font-bold text-discord-text-muted uppercase tracking-wider px-2 mb-1", children: ["OFFLINE \u2014 ", offlineMembers.length] }), offlineMembers.map((m) => renderMember(m, true))] }))] }) })); } diff --git a/packages/web/src/components/layout/MemberSidebar.tsx b/packages/web/src/components/layout/MemberSidebar.tsx index dac319f0..08dafcb8 100644 --- a/packages/web/src/components/layout/MemberSidebar.tsx +++ b/packages/web/src/components/layout/MemberSidebar.tsx @@ -1,18 +1,38 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import type { MemberWithUser } from '@opencord/shared'; import { useServerStore } from '../../stores/serverStore'; import { useUIStore } from '../../stores/uiStore'; import { Avatar } from '../ui/Avatar'; +const ROLE_ORDER: Record = { owner: 0, admin: 1, member: 2 }; +const ROLE_LABELS: Record = { owner: 'OWNER', admin: 'ADMIN', member: 'MEMBER' }; + export function MemberSidebar() { const members = useServerStore((s) => s.members); const memberListOpen = useUIStore((s) => s.memberListOpen); const openUserProfile = useUIStore((s) => s.openUserProfile); - if (!memberListOpen) return null; + const { roleGroups, offlineMembers } = useMemo(() => { + const online = members.filter(m => m.user.status !== 'offline'); + const offline = members.filter(m => m.user.status === 'offline'); - const onlineMembers = members.filter(m => m.user.status !== 'offline'); - const offlineMembers = members.filter(m => m.user.status === 'offline'); + // Group online members by role + const groups = new Map(); + for (const m of online) { + const role = m.role || 'member'; + if (!groups.has(role)) groups.set(role, []); + groups.get(role)!.push(m); + } + + // Sort groups by role hierarchy + const sorted = [...groups.entries()].sort( + (a, b) => (ROLE_ORDER[a[0]] ?? 99) - (ROLE_ORDER[b[0]] ?? 99) + ); + + return { roleGroups: sorted, offlineMembers: offline }; + }, [members]); + + if (!memberListOpen) return null; const roleColors: Record = { owner: 'text-discord-red', @@ -22,7 +42,6 @@ export function MemberSidebar() { const getMemberColor = (member: MemberWithUser) => { if (member.roles && member.roles.length > 0) { - // Return the color of the first role (already sorted by position) return { color: member.roles[0]!.color }; } return undefined; @@ -33,79 +52,60 @@ export function MemberSidebar() { const rect = e.currentTarget.getBoundingClientRect(); openUserProfile(user, { top: Math.min(rect.top, window.innerHeight - 450), - left: rect.left - 316, // Open to the left of member sidebar + left: rect.left - 316, }); }; + const renderMember = (member: MemberWithUser, isOffline = false) => { + const displayName = member.user.displayName ?? member.user.username; + return ( +
handleMemberClick(e, member.user)} + className="flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors" + > + +
+
+ {displayName} +
+ {!isOffline && member.user.customStatus && ( +
{member.user.customStatus}
+ )} +
+
+ ); + }; + return (
- {/* Online */} - {onlineMembers.length > 0 && ( -
-

- Online — {onlineMembers.length} + {/* Role-based groups */} + {roleGroups.map(([role, groupMembers]) => ( +
+

+ {ROLE_LABELS[role] ?? role.toUpperCase()} — {groupMembers.length}

- {onlineMembers.map((member) => { - const displayName = member.user.displayName ?? member.user.username; - return ( -
handleMemberClick(e, member.user)} - className="flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors" - > - -
-
- {displayName} -
- {member.user.customStatus && ( -
{member.user.customStatus}
- )} -
-
- ); - })} + {groupMembers.map((m) => renderMember(m))}
- )} + ))} {/* Offline */} {offlineMembers.length > 0 && (
-

- Offline — {offlineMembers.length} +

+ OFFLINE — {offlineMembers.length}

- {offlineMembers.map((member) => { - const displayName = member.user.displayName ?? member.user.username; - return ( -
handleMemberClick(e, member.user)} - className="flex items-center gap-3 px-2 py-1.5 rounded-[4px] hover:bg-discord-modifier-hover cursor-pointer group transition-colors" - > - -
-
- {displayName} -
-
-
- ); - })} + {offlineMembers.map((m) => renderMember(m, true))}
)}

diff --git a/packages/web/src/components/layout/ServerSidebar.js b/packages/web/src/components/layout/ServerSidebar.js index 6d4ef302..a6d4c52b 100644 --- a/packages/web/src/components/layout/ServerSidebar.js +++ b/packages/web/src/components/layout/ServerSidebar.js @@ -27,7 +27,7 @@ function SidebarItem({ name, icon, active, onClick, type = 'server', actionType, } return `${base} ${active ? 'bg-discord-blurple rounded-[16px] text-white' : 'bg-discord-bg-primary rounded-[24px] hover:rounded-[16px] text-discord-text-primary hover:bg-discord-blurple hover:text-white'}`; }; - return (_jsxs("div", { className: "relative flex items-center mb-2 w-full justify-center", onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [(type === 'server' || type === 'dm') && (_jsx("div", { className: "absolute -left-0 w-2 h-12 flex items-center", children: _jsx("div", { className: `bg-white rounded-r-full transition-all duration-200 origin-left ${getPillHeight()} w-1` }) })), _jsx(Tooltip, { content: name, position: "right", children: _jsx("button", { onClick: onClick, className: getButtonClasses(), children: type === 'dm' ? (_jsx("svg", { width: "28", height: "20", viewBox: "0 0 28 20", fill: "currentColor", children: _jsx("path", { d: "M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.003 1.12145 12.9974 1.12145 11.0292 1.4184C10.8251 0.934541 10.5938 0.461742 10.3416 0C8.49215 0.318797 6.68934 0.879656 4.97882 1.67671C0.665804 8.44726 -0.364554 15.0614 0.225316 21.5765C2.41849 23.2105 4.70543 24.3115 7.04773 25.043C7.60419 24.2941 8.09868 23.4944 8.52321 22.6521C7.71966 22.3602 6.9466 21.9905 6.21274 21.5543C6.39845 21.4212 6.58011 21.2838 6.75775 21.1429C12.7568 23.8968 19.2811 23.8968 25.2422 21.1429C25.4199 21.2838 25.6015 21.4212 25.7873 21.5543C25.0534 21.9905 24.2804 22.3602 23.4768 22.6521C23.9013 23.4944 24.3958 24.2941 24.9523 25.043C27.2946 24.3115 29.5815 23.2105 31.7747 21.5765C32.4517 14.0051 30.5663 7.45459 26.0212 1.67671H23.0212Z", transform: "scale(0.85) translate(0, 0)" }) })) : type === 'action' ? (actionType === 'add' ? (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" }) })) : (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" }) }))) : icon ? (_jsx("img", { src: icon.startsWith('http') ? icon : `/api/uploads/${icon}`, alt: name, className: "w-full h-full object-cover" })) : (_jsx("span", { className: "text-[16px] font-medium", children: firstLetter })) }) })] })); + return (_jsxs("div", { className: "relative flex items-center mb-2 w-full justify-center", onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [(type === 'server' || type === 'dm') && (_jsx("div", { className: "absolute -left-0 w-2 h-12 flex items-center", children: _jsx("div", { className: `bg-white rounded-r-full transition-all duration-200 origin-left ${getPillHeight()} w-1` }) })), _jsx(Tooltip, { content: name, position: "right", children: _jsx("button", { onClick: onClick, className: getButtonClasses(), children: type === 'dm' ? (_jsx("svg", { width: "28", height: "20", viewBox: "0 0 28 20", fill: "currentColor", children: _jsx("path", { d: "M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.003 1.12145 12.9974 1.12145 11.0292 1.4184C10.8251 0.934541 10.5938 0.461742 10.3416 0C8.49215 0.318797 6.68934 0.879656 4.97882 1.67671C0.665804 8.44726 -0.364554 15.0614 0.225316 21.5765C2.41849 23.2105 4.70543 24.3115 7.04773 25.043C7.60419 24.2941 8.09868 23.4944 8.52321 22.6521C7.71966 22.3602 6.9466 21.9905 6.21274 21.5543C6.39845 21.4212 6.58011 21.2838 6.75775 21.1429C12.7568 23.8968 19.2811 23.8968 25.2422 21.1429C25.4199 21.2838 25.6015 21.4212 25.7873 21.5543C25.0534 21.9905 24.2804 22.3602 23.4768 22.6521C23.9013 23.4944 24.3958 24.2941 24.9523 25.043C27.2946 24.3115 29.5815 23.2105 31.7747 21.5765C32.4517 14.0051 30.5663 7.45459 26.0212 1.67671H23.0212Z", transform: "scale(0.85) translate(0, 0)" }) })) : type === 'action' ? (actionType === 'add' ? (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" }) })) : actionType === 'explore' ? (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.486 2 2 6.486 2 12s4.486 10 10 10 10-4.486 10-10S17.514 2 12 2zm0 18c-4.411 0-8-3.589-8-8s3.589-8 8-8 8 3.589 8 8-3.589 8-8 8zm-3.146-5.351l2.78-1.042 1.042-2.78-2.78 1.042-1.042 2.78zM14.5 7.5l-2.5 5-5 2.5 2.5-5 5-2.5z" }) })) : (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" }) }))) : icon ? (_jsx("img", { src: icon.startsWith('http') ? icon : `/api/uploads/${icon}`, alt: name, className: "w-full h-full object-cover" })) : (_jsx("span", { className: "text-[16px] font-medium", children: firstLetter })) }) })] })); } export function ServerSidebar() { const servers = useServerStore((s) => s.servers); @@ -40,17 +40,21 @@ export function ServerSidebar() { const openModal = useUIStore((s) => s.openModal); const unreadChannels = useChatStore((s) => s.unreadChannels); const navigate = useNavigate(); + // Compute which servers have unread channels const unreadServerIds = useMemo(() => { const ids = new Set(); for (const channelId of unreadChannels) { const serverId = channelToServerMap.get(channelId); - if (serverId) ids.add(serverId); + if (serverId) + ids.add(serverId); } return ids; }, [unreadChannels, channelToServerMap]); + // Check if any DM channels are unread const hasDmUnread = useMemo(() => { for (const dm of dmChannels) { - if (unreadChannels.has(dm.id)) return true; + if (unreadChannels.has(dm.id)) + return true; } return false; }, [unreadChannels, dmChannels]); @@ -64,5 +68,5 @@ export function ServerSidebar() { setCurrentServer(null); navigate('/channels/@me'); }; - return (_jsxs("nav", { className: "w-[72px] bg-discord-bg-server flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none", children: [_jsx(SidebarItem, { id: "@me", name: "Direct Messages", active: showDms, onClick: handleDmClick, type: "dm", hasUnread: hasDmUnread }), _jsx("div", { className: "w-8 h-[2px] bg-discord-modifier-accent rounded-full mb-2" }), servers.map((server) => (_jsx(SidebarItem, { id: server.id, name: server.name, icon: server.icon, active: currentServerId === server.id, onClick: () => handleServerClick(server.id), hasUnread: unreadServerIds.has(server.id) }, server.id))), _jsx(SidebarItem, { id: "add-server", name: "Add a Server", active: false, onClick: () => openModal('createServer'), type: "action", actionType: "add" }), _jsx(SidebarItem, { id: "join-server", name: "Join a Server", active: false, onClick: () => openModal('joinServer'), type: "action", actionType: "join" })] })); + return (_jsxs("nav", { className: "w-[72px] bg-discord-bg-server flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none", children: [_jsx(SidebarItem, { id: "@me", name: "Direct Messages", active: showDms, onClick: handleDmClick, type: "dm", hasUnread: hasDmUnread }), _jsx("div", { className: "w-8 h-[2px] bg-discord-modifier-accent rounded-full mb-2" }), servers.map((server) => (_jsx(SidebarItem, { id: server.id, name: server.name, icon: server.icon, active: currentServerId === server.id, onClick: () => handleServerClick(server.id), hasUnread: unreadServerIds.has(server.id) }, server.id))), _jsx(SidebarItem, { id: "add-server", name: "Add a Server", active: false, onClick: () => openModal('createServer'), type: "action", actionType: "add" }), _jsx(SidebarItem, { id: "join-server", name: "Join a Server", active: false, onClick: () => openModal('joinServer'), type: "action", actionType: "join" }), _jsx(SidebarItem, { id: "explore", name: "Explore Discoverable Servers", active: false, onClick: () => { }, type: "action", actionType: "explore" })] })); } diff --git a/packages/web/src/components/layout/ServerSidebar.tsx b/packages/web/src/components/layout/ServerSidebar.tsx index 8c3d5e56..8e54e479 100644 --- a/packages/web/src/components/layout/ServerSidebar.tsx +++ b/packages/web/src/components/layout/ServerSidebar.tsx @@ -12,7 +12,7 @@ interface SidebarItemProps { active: boolean; onClick: () => void; type?: 'server' | 'dm' | 'action'; - actionType?: 'add' | 'join'; + actionType?: 'add' | 'join' | 'explore'; hasUnread?: boolean; } @@ -67,6 +67,10 @@ function SidebarItem({ name, icon, active, onClick, type = 'server', actionType, + ) : actionType === 'explore' ? ( + + + ) : ( @@ -171,6 +175,15 @@ export function ServerSidebar() { type="action" actionType="join" /> + + {}} + type="action" + actionType="explore" + /> ); } diff --git a/packages/web/src/components/modals/InviteModal.test.js b/packages/web/src/components/modals/InviteModal.test.js new file mode 100644 index 00000000..a4c8f298 --- /dev/null +++ b/packages/web/src/components/modals/InviteModal.test.js @@ -0,0 +1,95 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { InviteModal } from './InviteModal'; +import { useUIStore } from '../../stores/uiStore'; +import { useServerStore } from '../../stores/serverStore'; +// Mock the stores by spying on their getState +beforeEach(() => { + // Reset stores to default state + useUIStore.setState({ + activeModal: null, + modalData: {}, + }); + useServerStore.setState({ + currentServerId: null, + servers: [], + }); +}); +describe('InviteModal', () => { + it('does not render when activeModal is not "invite"', () => { + useUIStore.setState({ activeModal: null }); + render(_jsx(InviteModal, {})); + expect(screen.queryByText('Invite Friends')).not.toBeInTheDocument(); + }); + it('calls generateInvite and displays the invite URL when opened', async () => { + const mockGenerateInvite = vi.fn().mockResolvedValue('test-invite-code'); + useUIStore.setState({ activeModal: 'invite' }); + useServerStore.setState({ + currentServerId: 'server-123', + generateInvite: mockGenerateInvite, + }); + render(_jsx(InviteModal, {})); + // Modal title should be visible + expect(screen.getByText('Invite Friends')).toBeInTheDocument(); + // Should show "Generating..." initially + expect(screen.getByDisplayValue('Generating...')).toBeInTheDocument(); + // Wait for the invite code to load + await waitFor(() => { + const input = screen.getByDisplayValue(/\/join\/test-invite-code/); + expect(input).toBeInTheDocument(); + }); + // generateInvite should have been called with the server ID + expect(mockGenerateInvite).toHaveBeenCalledWith('server-123'); + }); + it('displays an error when generateInvite fails', async () => { + const mockGenerateInvite = vi.fn().mockRejectedValue(new Error('Not authorized')); + useUIStore.setState({ activeModal: 'invite' }); + useServerStore.setState({ + currentServerId: 'server-123', + generateInvite: mockGenerateInvite, + }); + render(_jsx(InviteModal, {})); + await waitFor(() => { + expect(screen.getByText('Not authorized')).toBeInTheDocument(); + }); + }); + it('Copy button is disabled while loading', () => { + const mockGenerateInvite = vi.fn().mockReturnValue(new Promise(() => { })); // never resolves + useUIStore.setState({ activeModal: 'invite' }); + useServerStore.setState({ + currentServerId: 'server-123', + generateInvite: mockGenerateInvite, + }); + render(_jsx(InviteModal, {})); + const copyButton = screen.getByText('Copy'); + expect(copyButton).toBeDisabled(); + }); + it('Copy button calls clipboard.writeText with the invite URL', async () => { + const user = userEvent.setup(); + const mockGenerateInvite = vi.fn().mockResolvedValue('abc123'); + const mockClipboard = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: mockClipboard }, + writable: true, + configurable: true, + }); + useUIStore.setState({ activeModal: 'invite' }); + useServerStore.setState({ + currentServerId: 'server-123', + generateInvite: mockGenerateInvite, + }); + render(_jsx(InviteModal, {})); + // Wait for invite to load + await waitFor(() => { + expect(screen.getByDisplayValue(/\/join\/abc123/)).toBeInTheDocument(); + }); + // Click copy + const copyButton = screen.getByText('Copy'); + await user.click(copyButton); + expect(mockClipboard).toHaveBeenCalledWith(expect.stringContaining('/join/abc123')); + // Button text should change to "Copied!" + expect(screen.getByText('Copied!')).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/modals/JoinServer.test.js b/packages/web/src/components/modals/JoinServer.test.js new file mode 100644 index 00000000..1fc0600f --- /dev/null +++ b/packages/web/src/components/modals/JoinServer.test.js @@ -0,0 +1,86 @@ +import { jsx as _jsx } from "react/jsx-runtime"; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { JoinServerModal } from './JoinServer'; +import { useUIStore } from '../../stores/uiStore'; +import { useServerStore } from '../../stores/serverStore'; +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); +beforeEach(() => { + mockNavigate.mockClear(); + useUIStore.setState({ activeModal: null }); + useServerStore.setState({ + servers: [], + currentServerId: null, + }); +}); +function renderModal() { + return render(_jsx(MemoryRouter, { children: _jsx(JoinServerModal, {}) })); +} +describe('JoinServerModal', () => { + it('does not render when activeModal is not "joinServer"', () => { + useUIStore.setState({ activeModal: null }); + renderModal(); + expect(screen.queryByText('Join a Server')).not.toBeInTheDocument(); + }); + it('renders the form when opened', () => { + useUIStore.setState({ activeModal: 'joinServer' }); + renderModal(); + expect(screen.getByText('Join a Server')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('e.g. abc123')).toBeInTheDocument(); + expect(screen.getByText('Join Server')).toBeInTheDocument(); + }); + it('shows validation error when submitting empty code', async () => { + const user = userEvent.setup(); + useUIStore.setState({ activeModal: 'joinServer' }); + renderModal(); + const submitButton = screen.getByText('Join Server'); + await user.click(submitButton); + expect(screen.getByText('Invite code is required')).toBeInTheDocument(); + }); + it('calls joinByCode with the entered invite code and navigates on success', async () => { + const user = userEvent.setup(); + const mockJoinByCode = vi.fn().mockResolvedValue({ id: 'new-server-id', name: 'Test Server' }); + useUIStore.setState({ activeModal: 'joinServer' }); + useServerStore.setState({ joinByCode: mockJoinByCode }); + renderModal(); + // Type invite code + const input = screen.getByPlaceholderText('e.g. abc123'); + await user.type(input, 'my-invite-code'); + // Click join + const submitButton = screen.getByText('Join Server'); + await user.click(submitButton); + // joinByCode should be called with the code + await waitFor(() => { + expect(mockJoinByCode).toHaveBeenCalledWith('my-invite-code'); + }); + // Should navigate to the new server + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/channels/new-server-id'); + }); + // Modal should close (activeModal becomes null) + expect(useUIStore.getState().activeModal).toBeNull(); + }); + it('shows error message when joinByCode fails', async () => { + const user = userEvent.setup(); + const mockJoinByCode = vi.fn().mockRejectedValue(new Error('Invalid invite code')); + useUIStore.setState({ activeModal: 'joinServer' }); + useServerStore.setState({ joinByCode: mockJoinByCode }); + renderModal(); + const input = screen.getByPlaceholderText('e.g. abc123'); + await user.type(input, 'bad-code'); + const submitButton = screen.getByText('Join Server'); + await user.click(submitButton); + await waitFor(() => { + expect(screen.getByText('Invalid invite code')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/web/src/components/modals/NewDmModal.js b/packages/web/src/components/modals/NewDmModal.js index 4bccbadc..325b4eeb 100644 --- a/packages/web/src/components/modals/NewDmModal.js +++ b/packages/web/src/components/modals/NewDmModal.js @@ -63,5 +63,5 @@ export function NewDmModal() { setError(err.message || 'Failed to create DM'); } }; - return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "New Direct Message", children: _jsxs("div", { className: "space-y-3", children: [_jsx("input", { ref: inputRef, type: "text", value: query, onChange: (e) => handleSearch(e.target.value), placeholder: "Search for a user...", className: "w-full px-3 py-2 bg-discord-bg-tertiary text-discord-text-primary placeholder-discord-text-muted/60 rounded-[4px] text-[14px] outline-none focus:ring-1 focus:ring-discord-blurple" }), error && _jsx("p", { className: "text-discord-red text-[13px]", children: error }), _jsxs("div", { className: "max-h-[300px] overflow-y-auto space-y-[2px]", children: [isSearching && _jsx("div", { className: "py-4 text-center text-discord-text-muted text-[14px]", children: "Searching..." }), !isSearching && query.trim().length >= 2 && results.length === 0 && _jsx("div", { className: "py-4 text-center text-discord-text-muted text-[14px]", children: "No users found" }), results.map((user) => (_jsx("button", { onClick: () => handleSelectUser(user), className: "w-full flex items-center gap-3 px-3 py-2 rounded-[4px] hover:bg-discord-modifier-hover transition-colors text-left", children: _jsxs("div", { className: "flex items-center gap-3 flex-1 min-w-0", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 36, status: user.status }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-[14px] font-medium text-discord-text-primary truncate", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[12px] text-discord-text-muted truncate", children: ["@", user.username] })] })] }) }, user.id)))] })] }) })); + return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "New Direct Message", children: _jsxs("div", { className: "space-y-3", children: [_jsx("input", { ref: inputRef, type: "text", value: query, onChange: (e) => handleSearch(e.target.value), placeholder: "Search for a user...", className: "w-full px-3 py-2 bg-discord-bg-tertiary text-discord-text-primary placeholder-discord-text-muted/60 rounded-[4px] text-[14px] outline-none focus:ring-1 focus:ring-discord-blurple" }), error && (_jsx("p", { className: "text-discord-red text-[13px]", children: error })), _jsxs("div", { className: "max-h-[300px] overflow-y-auto space-y-[2px]", children: [isSearching && (_jsx("div", { className: "py-4 text-center text-discord-text-muted text-[14px]", children: "Searching..." })), !isSearching && query.trim().length >= 2 && results.length === 0 && (_jsx("div", { className: "py-4 text-center text-discord-text-muted text-[14px]", children: "No users found" })), results.map((user) => (_jsxs("button", { onClick: () => handleSelectUser(user), className: "w-full flex items-center gap-3 px-3 py-2 rounded-[4px] hover:bg-discord-modifier-hover transition-colors text-left", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 36, status: user.status }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-[14px] font-medium text-discord-text-primary truncate", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[12px] text-discord-text-muted truncate", children: ["@", user.username] })] })] }, user.id)))] })] }) })); } diff --git a/packages/web/src/components/ui/UserProfilePopout.js b/packages/web/src/components/ui/UserProfilePopout.js index 85453840..ea5a0bd6 100644 --- a/packages/web/src/components/ui/UserProfilePopout.js +++ b/packages/web/src/components/ui/UserProfilePopout.js @@ -3,17 +3,16 @@ import { useNavigate } from 'react-router-dom'; import { Avatar } from '../ui/Avatar'; import { api } from '../../api/client'; import { useServerStore } from '../../stores/serverStore'; -import { useChatStore } from '../../stores/chatStore'; +import { useUIStore } from '../../stores/uiStore'; export function UserProfilePopout({ user, onClose, position }) { const navigate = useNavigate(); const addDmChannel = useServerStore((s) => s.addDmChannel); - const setCurrentChannel = useChatStore((s) => s.setCurrentChannel); const displayName = user.displayName ?? user.username; const handleSendMessage = async () => { try { const channel = await api.dm.create({ userId: user.id }); addDmChannel(channel); - setCurrentChannel(channel.id); + useUIStore.getState().setShowDms(true); onClose(); navigate(`/channels/@me/${channel.id}`); } diff --git a/packages/web/src/components/voice/DmCallView.js b/packages/web/src/components/voice/DmCallView.js new file mode 100644 index 00000000..7e6db53d --- /dev/null +++ b/packages/web/src/components/voice/DmCallView.js @@ -0,0 +1,115 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useEffect } from 'react'; +import { useVoiceStore } from '../../stores/voiceStore'; +import { useServerStore } from '../../stores/serverStore'; +import { useAuthStore } from '../../stores/authStore'; +import { getActiveRoom } from '../../hooks/useLiveKit'; +import { wsSend } from '../../hooks/useWebSocket'; +import { VideoPreset } from 'livekit-client'; +const QUALITY_MAP = { + '1080p60': new VideoPreset(1920, 1080, 15_000_000, 60), + '1080p': new VideoPreset(1920, 1080, 8_000_000, 30), + '720p60': new VideoPreset(1280, 720, 8_000_000, 60), + '720p': new VideoPreset(1280, 720, 5_000_000, 30), + '540p': new VideoPreset(960, 540, 2_000_000, 30), + '360p': new VideoPreset(640, 360, 1_000_000, 30), +}; +export function DmCallView() { + const activeDmCall = useVoiceStore((s) => s.activeDmCall); + const participants = useVoiceStore((s) => s.participants); + const isMuted = useVoiceStore((s) => s.isMuted); + const isDeafened = useVoiceStore((s) => s.isDeafened); + const isCameraOn = useVoiceStore((s) => s.isCameraOn); + const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); + const toggleMic = useVoiceStore((s) => s.toggleMic); + const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); + const toggleCamera = useVoiceStore((s) => s.toggleCamera); + const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare); + const setActiveDmCall = useVoiceStore((s) => s.setActiveDmCall); + const leaveVoice = useVoiceStore((s) => s.leaveVoice); + const dmChannels = useServerStore((s) => s.dmChannels); + const authUser = useAuthStore((s) => s.user); + const dmChannel = dmChannels.find(dm => dm.id === activeDmCall?.dmChannelId); + const otherUser = dmChannel?.members.find(m => m.id !== authUser?.id); + const otherName = otherUser?.displayName ?? otherUser?.username ?? 'User'; + const handleMute = () => { + const room = getActiveRoom(); + if (room) { + room.localParticipant.setMicrophoneEnabled(isMuted); + } + toggleMic(); + }; + const handleDeafen = () => { + const room = getActiveRoom(); + if (room) { + const newDeafened = !isDeafened; + room.remoteParticipants.forEach((p) => { + p.audioTrackPublications.forEach((pub) => { + if (pub.track) { + pub.track.setVolume?.(newDeafened ? 0 : 1); + } + }); + }); + if (newDeafened) { + room.localParticipant.setMicrophoneEnabled(false); + } + else if (!isMuted) { + room.localParticipant.setMicrophoneEnabled(true); + } + } + toggleDeafen(); + }; + const handleCamera = async () => { + const room = getActiveRoom(); + if (room) { + const willEnable = !isCameraOn; + if (willEnable) { + const videoQuality = useVoiceStore.getState().videoQuality; + const preset = QUALITY_MAP[videoQuality]; + if (preset) { + await room.localParticipant.setCameraEnabled(true, { resolution: preset.resolution }, { + videoEncoding: preset.encoding, + simulcast: videoQuality === '1080p' || videoQuality === '720p' + }); + } + else { + await room.localParticipant.setCameraEnabled(true); + } + } + else { + await room.localParticipant.setCameraEnabled(false); + } + } + toggleCamera(); + }; + const handleScreenShare = () => { + const room = getActiveRoom(); + if (room) { + room.localParticipant.setScreenShareEnabled(!isScreenSharing); + } + toggleScreenShare(); + }; + const handleEndCall = () => { + if (activeDmCall) { + wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }); + } + setActiveDmCall(null); + leaveVoice(); + }; + // Attach video elements + useEffect(() => { + participants.forEach((p) => { + if (p.videoTrack) { + const el = document.getElementById(`dm-video-${p.userId}`); + if (el && el.srcObject?.getVideoTracks()[0]?.id !== p.videoTrack.id) { + el.srcObject = new MediaStream([p.videoTrack]); + } + } + }); + }, [participants]); + if (!activeDmCall) + return null; + const localParticipant = participants.find(p => p.isLocal); + const remoteParticipant = participants.find(p => !p.isLocal); + return (_jsxs("div", { className: "flex-1 flex flex-col bg-[#111214] min-w-0", children: [_jsx("div", { className: "h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 bg-[#111214]", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-green", children: _jsx("path", { d: "M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: otherName }), _jsx("span", { className: "text-xs text-discord-green font-medium ml-2", children: "In Call" })] }) }), _jsxs("div", { className: "flex-1 flex items-center justify-center gap-8 p-8", children: [_jsxs("div", { className: "flex flex-col items-center gap-4", children: [remoteParticipant?.videoTrack ? (_jsxs("div", { className: "w-[360px] h-[270px] rounded-xl overflow-hidden bg-[#2b2d31] relative", children: [_jsx("video", { id: `dm-video-${remoteParticipant.userId}`, autoPlay: true, playsInline: true, muted: false, className: "w-full h-full object-cover" }), _jsx("div", { className: "absolute bottom-2 left-2 px-2 py-1 bg-black/60 rounded text-xs text-white", children: otherName })] })) : (_jsxs("div", { className: "w-[200px] h-[200px] rounded-full bg-[#2b2d31] flex items-center justify-center relative", children: [_jsx("div", { className: "w-24 h-24 rounded-full bg-discord-blurple flex items-center justify-center text-white text-4xl font-bold", children: otherName.charAt(0).toUpperCase() }), remoteParticipant?.isSpeaking && (_jsx("div", { className: "absolute inset-0 rounded-full ring-[3px] ring-discord-green" }))] })), _jsx("span", { className: "text-discord-text-secondary text-sm font-medium", children: remoteParticipant ? otherName : 'Connecting...' }), remoteParticipant?.isMuted && (_jsxs("span", { className: "text-discord-text-muted text-xs flex items-center gap-1", children: [_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3L3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z" }) }), "Muted"] }))] }), _jsxs("div", { className: "flex flex-col items-center gap-4", children: [localParticipant?.videoTrack ? (_jsxs("div", { className: "w-[360px] h-[270px] rounded-xl overflow-hidden bg-[#2b2d31] relative", children: [_jsx("video", { id: `dm-video-${localParticipant.userId}`, autoPlay: true, playsInline: true, muted: true, className: "w-full h-full object-cover mirror", style: { transform: 'scaleX(-1)' } }), _jsx("div", { className: "absolute bottom-2 left-2 px-2 py-1 bg-black/60 rounded text-xs text-white", children: "You" })] })) : (_jsxs("div", { className: "w-[200px] h-[200px] rounded-full bg-[#2b2d31] flex items-center justify-center relative", children: [_jsx("div", { className: "w-24 h-24 rounded-full bg-discord-blurple flex items-center justify-center text-white text-4xl font-bold", children: (authUser?.displayName ?? authUser?.username ?? 'Y').charAt(0).toUpperCase() }), localParticipant?.isSpeaking && (_jsx("div", { className: "absolute inset-0 rounded-full ring-[3px] ring-discord-green" }))] })), _jsxs("span", { className: "text-discord-text-secondary text-sm font-medium", children: [authUser?.displayName ?? authUser?.username ?? 'You', " (You)"] })] })] }), _jsxs("div", { className: "h-[72px] bg-[#1e1f22] flex items-center justify-center gap-4 px-4 flex-shrink-0", children: [_jsx("button", { onClick: handleMute, className: `w-12 h-12 rounded-full flex items-center justify-center transition-colors ${isMuted ? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30' : 'bg-[#2b2d31] text-discord-text-primary hover:bg-[#36373d]'}`, title: isMuted ? 'Unmute' : 'Mute', children: isMuted ? (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3L3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z" }) })) : (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 14c1.66 0 2.99-1.34 2.99-3L15 5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z" }) })) }), _jsx("button", { onClick: handleDeafen, className: `w-12 h-12 rounded-full flex items-center justify-center transition-colors ${isDeafened ? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30' : 'bg-[#2b2d31] text-discord-text-primary hover:bg-[#36373d]'}`, title: isDeafened ? 'Undeafen' : 'Deafen', children: isDeafened ? (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M3.63 3.63a.996.996 0 000 1.41L7.29 8.7 7 9H4c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71v-4.17l4.18 4.18c-.49.37-1.02.68-1.6.91-.36.15-.58.53-.58.92 0 .72.73 1.18 1.39.91.8-.33 1.55-.77 2.22-1.31l1.34 1.34a.996.996 0 101.41-1.41L5.05 3.63c-.39-.39-1.02-.39-1.42 0zM19 12c0 .82-.15 1.61-.41 2.34l1.53 1.53c.56-1.17.88-2.48.88-3.87 0-3.83-2.4-7.11-5.78-8.4-.59-.23-1.22.23-1.22.86v.19c0 .38.25.71.61.85C17.18 6.54 19 9.06 19 12zm-8.71-6.29l-.17.17L12 7.76V6.41c0-.89-1.08-1.33-1.71-.7zM16.5 12A4.5 4.5 0 0014 7.97v1.79l2.48 2.48c.01-.08.02-.16.02-.24z" }) })) : (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" }) })) }), _jsx("button", { onClick: handleCamera, className: `w-12 h-12 rounded-full flex items-center justify-center transition-colors ${isCameraOn ? 'bg-discord-blurple/20 text-discord-blurple hover:bg-discord-blurple/30' : 'bg-[#2b2d31] text-discord-text-primary hover:bg-[#36373d]'}`, title: isCameraOn ? 'Turn Off Camera' : 'Turn On Camera', children: isCameraOn ? (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" }) })) : (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M21 6.5l-4 4V7c0-.55-.45-1-1-1H9.82L21 17.18V6.5zM3.27 2L2 3.27 4.73 6H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.21 0 .39-.08.54-.18L19.73 21 21 19.73 3.27 2z" }) })) }), _jsx("button", { onClick: handleScreenShare, className: `w-12 h-12 rounded-full flex items-center justify-center transition-colors ${isScreenSharing ? 'bg-discord-blurple/20 text-discord-blurple hover:bg-discord-blurple/30' : 'bg-[#2b2d31] text-discord-text-primary hover:bg-[#36373d]'}`, title: isScreenSharing ? 'Stop Sharing' : 'Share Screen', children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M20 18c1.1 0 1.99-.9 1.99-2L22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2H0v2h24v-2h-4zM4 6h16v10H4V6z" }) }) }), _jsx("div", { className: "w-[1px] h-8 bg-[#3f4147] mx-2" }), _jsx("button", { onClick: handleEndCall, className: "w-12 h-12 rounded-full bg-discord-red hover:bg-discord-red/80 flex items-center justify-center transition-colors text-white", title: "End Call", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08c-.18-.17-.29-.42-.29-.7 0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z" }) }) })] })] })); +} diff --git a/packages/web/src/components/voice/DmCallView.tsx b/packages/web/src/components/voice/DmCallView.tsx new file mode 100644 index 00000000..db9bda7b --- /dev/null +++ b/packages/web/src/components/voice/DmCallView.tsx @@ -0,0 +1,298 @@ +import React, { useEffect } from 'react'; +import { useVoiceStore } from '../../stores/voiceStore'; +import { useServerStore } from '../../stores/serverStore'; +import { useAuthStore } from '../../stores/authStore'; +import { getActiveRoom } from '../../hooks/useLiveKit'; +import { wsSend } from '../../hooks/useWebSocket'; +import { VideoPresets, VideoPreset } from 'livekit-client'; + +const QUALITY_MAP: Record = { + '1080p60': new VideoPreset(1920, 1080, 15_000_000, 60), + '1080p': new VideoPreset(1920, 1080, 8_000_000, 30), + '720p60': new VideoPreset(1280, 720, 8_000_000, 60), + '720p': new VideoPreset(1280, 720, 5_000_000, 30), + '540p': new VideoPreset(960, 540, 2_000_000, 30), + '360p': new VideoPreset(640, 360, 1_000_000, 30), +}; + +export function DmCallView() { + const activeDmCall = useVoiceStore((s) => s.activeDmCall); + const participants = useVoiceStore((s) => s.participants); + const isMuted = useVoiceStore((s) => s.isMuted); + const isDeafened = useVoiceStore((s) => s.isDeafened); + const isCameraOn = useVoiceStore((s) => s.isCameraOn); + const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); + const toggleMic = useVoiceStore((s) => s.toggleMic); + const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); + const toggleCamera = useVoiceStore((s) => s.toggleCamera); + const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare); + const setActiveDmCall = useVoiceStore((s) => s.setActiveDmCall); + const leaveVoice = useVoiceStore((s) => s.leaveVoice); + + const dmChannels = useServerStore((s) => s.dmChannels); + const authUser = useAuthStore((s) => s.user); + + const dmChannel = dmChannels.find(dm => dm.id === activeDmCall?.dmChannelId); + const otherUser = dmChannel?.members.find(m => m.id !== authUser?.id); + const otherName = otherUser?.displayName ?? otherUser?.username ?? 'User'; + + const handleMute = () => { + const room = getActiveRoom(); + if (room) { + room.localParticipant.setMicrophoneEnabled(isMuted); + } + toggleMic(); + }; + + const handleDeafen = () => { + const room = getActiveRoom(); + if (room) { + const newDeafened = !isDeafened; + room.remoteParticipants.forEach((p) => { + p.audioTrackPublications.forEach((pub) => { + if (pub.track) { + (pub.track as any).setVolume?.(newDeafened ? 0 : 1); + } + }); + }); + if (newDeafened) { + room.localParticipant.setMicrophoneEnabled(false); + } else if (!isMuted) { + room.localParticipant.setMicrophoneEnabled(true); + } + } + toggleDeafen(); + }; + + const handleCamera = async () => { + const room = getActiveRoom(); + if (room) { + const willEnable = !isCameraOn; + if (willEnable) { + const videoQuality = useVoiceStore.getState().videoQuality; + const preset = QUALITY_MAP[videoQuality]; + if (preset) { + await room.localParticipant.setCameraEnabled(true, + { resolution: preset.resolution }, + { + videoEncoding: preset.encoding, + simulcast: videoQuality === '1080p' || videoQuality === '720p' + } + ); + } else { + await room.localParticipant.setCameraEnabled(true); + } + } else { + await room.localParticipant.setCameraEnabled(false); + } + } + toggleCamera(); + }; + + const handleScreenShare = () => { + const room = getActiveRoom(); + if (room) { + room.localParticipant.setScreenShareEnabled(!isScreenSharing); + } + toggleScreenShare(); + }; + + const handleEndCall = () => { + if (activeDmCall) { + wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }); + } + setActiveDmCall(null); + leaveVoice(); + }; + + // Attach video elements + useEffect(() => { + participants.forEach((p) => { + if (p.videoTrack) { + const el = document.getElementById(`dm-video-${p.userId}`) as HTMLVideoElement | null; + if (el && (el.srcObject as MediaStream | null)?.getVideoTracks()[0]?.id !== p.videoTrack.id) { + el.srcObject = new MediaStream([p.videoTrack]); + } + } + }); + }, [participants]); + + if (!activeDmCall) return null; + + const localParticipant = participants.find(p => p.isLocal); + const remoteParticipant = participants.find(p => !p.isLocal); + + return ( +
+ {/* Header */} +
+
+ + + + {otherName} + In Call +
+
+ + {/* Main call area - 1-on-1 layout */} +
+ {/* Remote participant (or waiting) */} +
+ {remoteParticipant?.videoTrack ? ( +
+
+ ) : ( +
+
+ {otherName.charAt(0).toUpperCase()} +
+ {remoteParticipant?.isSpeaking && ( +
+ )} +
+ )} + + {remoteParticipant ? otherName : 'Connecting...'} + + {remoteParticipant?.isMuted && ( + + + + + Muted + + )} +
+ + {/* Local participant */} +
+ {localParticipant?.videoTrack ? ( +
+
+ ) : ( +
+
+ {(authUser?.displayName ?? authUser?.username ?? 'Y').charAt(0).toUpperCase()} +
+ {localParticipant?.isSpeaking && ( +
+ )} +
+ )} + + {authUser?.displayName ?? authUser?.username ?? 'You'} (You) + +
+
+ + {/* Control bar */} +
+ {/* Mute */} + + + {/* Deafen */} + + + {/* Camera */} + + + {/* Screen Share */} + + + {/* Spacer */} +
+ + {/* End Call */} + +
+
+ ); +} diff --git a/packages/web/src/components/voice/IncomingCallModal.js b/packages/web/src/components/voice/IncomingCallModal.js new file mode 100644 index 00000000..dd48a2ea --- /dev/null +++ b/packages/web/src/components/voice/IncomingCallModal.js @@ -0,0 +1,39 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useEffect, useRef } from 'react'; +import { useVoiceStore } from '../../stores/voiceStore'; +import { wsSend } from '../../hooks/useWebSocket'; +export function IncomingCallModal() { + const incomingCall = useVoiceStore((s) => s.incomingCall); + const setIncomingCall = useVoiceStore((s) => s.setIncomingCall); + const timerRef = useRef(null); + // Auto-dismiss after 30 seconds + useEffect(() => { + if (incomingCall) { + timerRef.current = setTimeout(() => { + // Auto-reject after timeout + wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId }); + setIncomingCall(null); + }, 30000); + } + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [incomingCall, setIncomingCall]); + if (!incomingCall) + return null; + const handleAccept = () => { + if (timerRef.current) + clearTimeout(timerRef.current); + wsSend({ type: 'dm_call_accept', dmChannelId: incomingCall.dmChannelId }); + }; + const handleDecline = () => { + if (timerRef.current) + clearTimeout(timerRef.current); + wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId }); + setIncomingCall(null); + }; + return (_jsxs("div", { className: "fixed inset-0 z-[100] flex items-center justify-center", children: [_jsx("div", { className: "absolute inset-0 bg-black/60" }), _jsxs("div", { className: "relative bg-[#1e1f22] rounded-lg shadow-2xl w-[340px] overflow-hidden", children: [_jsxs("div", { className: "absolute inset-0 overflow-hidden", children: [_jsx("div", { className: "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[200px] h-[200px] rounded-full bg-discord-green/5 animate-ping", style: { animationDuration: '2s' } }), _jsx("div", { className: "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[150px] h-[150px] rounded-full bg-discord-green/10 animate-ping", style: { animationDuration: '2s', animationDelay: '0.5s' } })] }), _jsxs("div", { className: "relative p-8 flex flex-col items-center gap-4", children: [_jsxs("div", { className: "relative", children: [_jsx("div", { className: "w-20 h-20 rounded-full bg-discord-blurple flex items-center justify-center text-white text-3xl font-bold", children: incomingCall.callerName.charAt(0).toUpperCase() }), _jsx("div", { className: "absolute -bottom-1 -right-1 w-7 h-7 rounded-full bg-discord-green flex items-center justify-center", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "white", children: _jsx("path", { d: "M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" }) }) })] }), _jsxs("div", { className: "text-center", children: [_jsx("h3", { className: "text-[20px] font-bold text-discord-text-header", children: incomingCall.callerName }), _jsx("p", { className: "text-[14px] text-discord-text-muted mt-1", children: "Incoming Voice Call..." })] }), _jsxs("div", { className: "flex items-center gap-6 mt-2", children: [_jsx("button", { onClick: handleDecline, className: "w-14 h-14 rounded-full bg-discord-red hover:bg-discord-red/80 flex items-center justify-center transition-colors group", title: "Decline", children: _jsx("svg", { width: "28", height: "28", viewBox: "0 0 24 24", fill: "white", className: "group-hover:scale-110 transition-transform", children: _jsx("path", { d: "M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08c-.18-.17-.29-.42-.29-.7 0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z" }) }) }), _jsx("button", { onClick: handleAccept, className: "w-14 h-14 rounded-full bg-discord-green hover:bg-discord-green/80 flex items-center justify-center transition-colors group", title: "Accept", children: _jsx("svg", { width: "28", height: "28", viewBox: "0 0 24 24", fill: "white", className: "group-hover:scale-110 transition-transform", children: _jsx("path", { d: "M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" }) }) })] })] })] })] })); +} diff --git a/packages/web/src/components/voice/IncomingCallModal.tsx b/packages/web/src/components/voice/IncomingCallModal.tsx new file mode 100644 index 00000000..981576f2 --- /dev/null +++ b/packages/web/src/components/voice/IncomingCallModal.tsx @@ -0,0 +1,102 @@ +import React, { useEffect, useRef } from 'react'; +import { useVoiceStore } from '../../stores/voiceStore'; +import { wsSend } from '../../hooks/useWebSocket'; + +export function IncomingCallModal() { + const incomingCall = useVoiceStore((s) => s.incomingCall); + const setIncomingCall = useVoiceStore((s) => s.setIncomingCall); + const timerRef = useRef | null>(null); + + // Auto-dismiss after 30 seconds + useEffect(() => { + if (incomingCall) { + timerRef.current = setTimeout(() => { + // Auto-reject after timeout + wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId }); + setIncomingCall(null); + }, 30000); + } + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [incomingCall, setIncomingCall]); + + if (!incomingCall) return null; + + const handleAccept = () => { + if (timerRef.current) clearTimeout(timerRef.current); + wsSend({ type: 'dm_call_accept', dmChannelId: incomingCall.dmChannelId }); + }; + + const handleDecline = () => { + if (timerRef.current) clearTimeout(timerRef.current); + wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId }); + setIncomingCall(null); + }; + + return ( +
+ {/* Backdrop */} +
+ + {/* Call card */} +
+ {/* Ring animation background */} +
+
+
+
+ + {/* Content */} +
+ {/* Caller avatar */} +
+
+ {incomingCall.callerName.charAt(0).toUpperCase()} +
+ {/* Ringing phone icon */} +
+ + + +
+
+ + {/* Caller info */} +
+

{incomingCall.callerName}

+

Incoming Voice Call...

+
+ + {/* Action buttons */} +
+ {/* Decline */} + + + {/* Accept */} + +
+
+
+
+ ); +} diff --git a/packages/web/src/components/voice/VideoQualityPopover.js b/packages/web/src/components/voice/VideoQualityPopover.js new file mode 100644 index 00000000..362b299f --- /dev/null +++ b/packages/web/src/components/voice/VideoQualityPopover.js @@ -0,0 +1,45 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { useRef, useEffect } from 'react'; +import { useVoiceStore } from '../../stores/voiceStore'; +import { VideoPreset } from 'livekit-client'; +const PRESETS = [ + { value: 'auto', label: 'Auto', desc: 'Adjusts to your connection' }, + { value: '1080p60', label: '1080p 60fps', desc: '1920x1080, 15000 kbps' }, + { value: '1080p', label: '1080p 30fps', desc: '1920x1080, 8000 kbps' }, + { value: '720p60', label: '720p 60fps', desc: '1280x720, 8000 kbps' }, + { value: '720p', label: '720p 30fps', desc: '1280x720, 5000 kbps' }, + { value: '540p', label: '540p 30fps', desc: '960x540, 2000 kbps' }, + { value: '360p', label: '360p 30fps', desc: '640x360, 1000 kbps' }, +]; +const QUALITY_MAP = { + '1080p60': new VideoPreset(1920, 1080, 15_000_000, 60), + '1080p': new VideoPreset(1920, 1080, 8_000_000, 30), + '720p60': new VideoPreset(1280, 720, 8_000_000, 60), + '720p': new VideoPreset(1280, 720, 5_000_000, 30), + '540p': new VideoPreset(960, 540, 2_000_000, 30), + '360p': new VideoPreset(640, 360, 1_000_000, 30), +}; +export function VideoQualityPopover({ open, onClose, anchorRect }) { + const popoverRef = useRef(null); + const videoQuality = useVoiceStore((s) => s.videoQuality); + const setVideoQuality = useVoiceStore((s) => s.setVideoQuality); + const isCameraOn = useVoiceStore((s) => s.isCameraOn); + useEffect(() => { + if (!open) + return; + const handleClick = (e) => { + if (popoverRef.current && !popoverRef.current.contains(e.target)) { + onClose(); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [open, onClose]); + if (!open) + return null; + const handleSelect = async (quality) => { + setVideoQuality(quality); + onClose(); + }; + return (_jsxs("div", { ref: popoverRef, className: "absolute bottom-full left-1/2 -translate-x-1/2 mb-3 w-[240px] bg-[#2b2d31] rounded-lg shadow-lg border border-[#1e1f22] z-50 overflow-hidden", children: [_jsx("div", { className: "px-3 py-2 border-b border-[#1e1f22]", children: _jsx("span", { className: "text-[14px] font-bold text-discord-text-primary", children: "Video Quality" }) }), _jsx("div", { className: "py-1", children: PRESETS.map((preset) => (_jsxs("button", { onClick: () => handleSelect(preset.value), className: `w-full px-3 py-2 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors ${videoQuality === preset.value ? 'text-discord-text-primary' : 'text-discord-text-secondary'}`, children: [_jsxs("div", { className: "text-left", children: [_jsx("div", { className: "text-[14px] font-medium", children: preset.label }), _jsx("div", { className: "text-[12px] text-discord-text-muted", children: preset.desc })] }), videoQuality === preset.value && (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-blurple flex-shrink-0 ml-2", children: _jsx("path", { d: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" }) }))] }, preset.value))) })] })); +} diff --git a/packages/web/src/components/voice/VideoQualityPopover.tsx b/packages/web/src/components/voice/VideoQualityPopover.tsx new file mode 100644 index 00000000..a7f73a9f --- /dev/null +++ b/packages/web/src/components/voice/VideoQualityPopover.tsx @@ -0,0 +1,86 @@ +import React, { useRef, useEffect } from 'react'; +import { useVoiceStore } from '../../stores/voiceStore'; +import { getActiveRoom } from '../../hooks/useLiveKit'; +import { VideoPresets, VideoPreset } from 'livekit-client'; + +interface VideoQualityPopoverProps { + open: boolean; + onClose: () => void; + anchorRect?: DOMRect | null; +} + +const PRESETS = [ + { value: 'auto' as const, label: 'Auto', desc: 'Adjusts to your connection' }, + { value: '1080p60' as const, label: '1080p 60fps', desc: '1920x1080, 15000 kbps' }, + { value: '1080p' as const, label: '1080p 30fps', desc: '1920x1080, 8000 kbps' }, + { value: '720p60' as const, label: '720p 60fps', desc: '1280x720, 8000 kbps' }, + { value: '720p' as const, label: '720p 30fps', desc: '1280x720, 5000 kbps' }, + { value: '540p' as const, label: '540p 30fps', desc: '960x540, 2000 kbps' }, + { value: '360p' as const, label: '360p 30fps', desc: '640x360, 1000 kbps' }, +] as const; + +const QUALITY_MAP: Record = { + '1080p60': new VideoPreset(1920, 1080, 15_000_000, 60), + '1080p': new VideoPreset(1920, 1080, 8_000_000, 30), + '720p60': new VideoPreset(1280, 720, 8_000_000, 60), + '720p': new VideoPreset(1280, 720, 5_000_000, 30), + '540p': new VideoPreset(960, 540, 2_000_000, 30), + '360p': new VideoPreset(640, 360, 1_000_000, 30), +}; + +export function VideoQualityPopover({ open, onClose, anchorRect }: VideoQualityPopoverProps) { + const popoverRef = useRef(null); + const videoQuality = useVoiceStore((s) => s.videoQuality); + const setVideoQuality = useVoiceStore((s) => s.setVideoQuality); + const isCameraOn = useVoiceStore((s) => s.isCameraOn); + + useEffect(() => { + if (!open) return; + const handleClick = (e: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [open, onClose]); + + if (!open) return null; + + const handleSelect = async (quality: typeof videoQuality) => { + setVideoQuality(quality); + onClose(); + }; + + return ( +
+
+ Video Quality +
+
+ {PRESETS.map((preset) => ( + + ))} +
+
+ ); +} diff --git a/packages/web/src/components/voice/VoiceChatPanel.js b/packages/web/src/components/voice/VoiceChatPanel.js new file mode 100644 index 00000000..de10e29a --- /dev/null +++ b/packages/web/src/components/voice/VoiceChatPanel.js @@ -0,0 +1,9 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import { MessageList } from '../chat/MessageList'; +import { MessageInput } from '../chat/MessageInput'; +import { TypingIndicator } from '../chat/TypingIndicator'; +import { useUIStore } from '../../stores/uiStore'; +export function VoiceChatPanel({ channelId, channelName }) { + const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat); + return (_jsxs("div", { className: "w-[340px] flex-shrink-0 bg-discord-bg-primary flex flex-col border-l border-[#2b2d31]", children: [_jsxs("div", { className: "h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0", children: [_jsx("span", { className: "font-bold text-discord-text-primary text-[16px]", children: "Chat" }), _jsx("button", { onClick: toggleVoiceChat, className: "w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded", title: "Close Chat", children: _jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" }) }) })] }), _jsx(MessageList, { channelId: channelId }), _jsx(TypingIndicator, { channelId: channelId }), _jsx(MessageInput, { channelId: channelId, channelName: channelName })] })); +} diff --git a/packages/web/src/components/voice/VoiceChatPanel.tsx b/packages/web/src/components/voice/VoiceChatPanel.tsx new file mode 100644 index 00000000..4d5e88ab --- /dev/null +++ b/packages/web/src/components/voice/VoiceChatPanel.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { MessageList } from '../chat/MessageList'; +import { MessageInput } from '../chat/MessageInput'; +import { TypingIndicator } from '../chat/TypingIndicator'; +import { useUIStore } from '../../stores/uiStore'; + +interface VoiceChatPanelProps { + channelId: string; + channelName: string; +} + +export function VoiceChatPanel({ channelId, channelName }: VoiceChatPanelProps) { + const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat); + + return ( +
+ {/* Chat header */} +
+ Chat + +
+ + {/* Messages */} + + + {/* Typing indicator */} + + + {/* Input */} + +
+ ); +} diff --git a/packages/web/src/components/voice/VoiceControlBar.js b/packages/web/src/components/voice/VoiceControlBar.js new file mode 100644 index 00000000..4556c1ab --- /dev/null +++ b/packages/web/src/components/voice/VoiceControlBar.js @@ -0,0 +1,164 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +import React, { useEffect, useState } from 'react'; +import { useVoiceStore } from '../../stores/voiceStore'; +import { useUIStore } from '../../stores/uiStore'; +import { getActiveRoom } from '../../hooks/useLiveKit'; +import { wsSend } from '../../hooks/useWebSocket'; +import { VideoQualityPopover } from './VideoQualityPopover'; +import { VideoPreset } from 'livekit-client'; +const QUALITY_MAP = { + '1080p60': new VideoPreset(1920, 1080, 15_000_000, 60), + '1080p': new VideoPreset(1920, 1080, 8_000_000, 30), + '720p60': new VideoPreset(1280, 720, 8_000_000, 60), + '720p': new VideoPreset(1280, 720, 5_000_000, 30), + '540p': new VideoPreset(960, 540, 2_000_000, 30), + '360p': new VideoPreset(640, 360, 1_000_000, 30), +}; +export function VoiceControlBar() { + const isMuted = useVoiceStore((s) => s.isMuted); + const isDeafened = useVoiceStore((s) => s.isDeafened); + const isCameraOn = useVoiceStore((s) => s.isCameraOn); + const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); + const toggleMic = useVoiceStore((s) => s.toggleMic); + const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); + const toggleCamera = useVoiceStore((s) => s.toggleCamera); + const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare); + const voiceChatOpen = useUIStore((s) => s.voiceChatOpen); + const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat); + const voiceFullscreen = useUIStore((s) => s.voiceFullscreen); + const toggleVoiceFullscreen = useUIStore((s) => s.toggleVoiceFullscreen); + const [qualityOpen, setQualityOpen] = useState(false); + const handleMute = React.useCallback(async () => { + const room = getActiveRoom(); + if (room) { + try { + await room.localParticipant.setMicrophoneEnabled(isMuted); + } + catch (err) { + console.error('[VoiceControlBar] Failed to toggle mic:', err); + } + } + toggleMic(); + }, [isMuted, toggleMic]); + const handleDeafen = React.useCallback(async () => { + const room = getActiveRoom(); + if (room) { + try { + const willDeafen = !isDeafened; + if (willDeafen) { + await room.localParticipant.setMicrophoneEnabled(false); + room.remoteParticipants.forEach((p) => p.setVolume(0)); + if (!isMuted) + toggleMic(); + } + else { + const outputVolume = useVoiceStore.getState().outputVolume; + room.remoteParticipants.forEach((p) => p.setVolume(outputVolume / 100)); + await room.localParticipant.setMicrophoneEnabled(true); + if (isMuted) + toggleMic(); + } + } + catch (err) { + console.error('[VoiceControlBar] Failed to toggle deafen:', err); + } + } + toggleDeafen(); + }, [isDeafened, isMuted, toggleDeafen, toggleMic]); + const handleCamera = async () => { + const room = getActiveRoom(); + if (!room) + return; + try { + const willEnable = !isCameraOn; + if (willEnable) { + const videoQuality = useVoiceStore.getState().videoQuality; + const preset = QUALITY_MAP[videoQuality]; + if (preset) { + await room.localParticipant.setCameraEnabled(true, { resolution: preset.resolution }, { + videoEncoding: preset.encoding, + simulcast: videoQuality === '1080p' || videoQuality === '720p' + }); + } + else { + await room.localParticipant.setCameraEnabled(true); + } + } + else { + await room.localParticipant.setCameraEnabled(false); + } + toggleCamera(); + } + catch (err) { + console.error('[VoiceControlBar] Failed to toggle camera:', err); + } + }; + const handleScreenShare = async () => { + const room = getActiveRoom(); + if (!room) + return; + try { + await room.localParticipant.setScreenShareEnabled(!isScreenSharing); + toggleScreenShare(); + } + catch (err) { + console.error('[VoiceControlBar] Failed to toggle screen share:', err); + } + }; + const handleDisconnect = () => { + wsSend({ type: 'voice_leave' }); + useVoiceStore.getState().leaveVoice(); + if (voiceFullscreen) { + useUIStore.getState().setVoiceFullscreen(false); + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => { }); + } + } + }; + const handleFullscreen = () => { + if (!voiceFullscreen) { + document.documentElement.requestFullscreen?.().catch(() => { }); + } + else { + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => { }); + } + } + toggleVoiceFullscreen(); + }; + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) + return; + if (e.key === 'm' || e.key === 'M') { + e.preventDefault(); + handleMute(); + } + else if (e.key === 'd' || e.key === 'D') { + e.preventDefault(); + handleDeafen(); + } + else if (e.key === 'Escape' && voiceFullscreen) { + useUIStore.getState().setVoiceFullscreen(false); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleMute, handleDeafen, voiceFullscreen]); + return (_jsxs("div", { className: "h-[72px] bg-[#1a1b1e] flex items-center justify-center gap-2 px-4 flex-shrink-0", children: [_jsx("button", { onClick: handleMute, className: `w-12 h-12 flex items-center justify-center rounded-full transition-colors ${isMuted || isDeafened + ? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30' + : 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'}`, title: isMuted ? 'Unmute (M)' : 'Mute (M)', children: _jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" }), _jsx("path", { d: "M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" }), (isMuted || isDeafened) && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] }) }), _jsx("button", { onClick: handleDeafen, className: `w-12 h-12 flex items-center justify-center rounded-full transition-colors ${isDeafened + ? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30' + : 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'}`, title: isDeafened ? 'Undeafen (D)' : 'Deafen (D)', children: _jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }), isDeafened && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] }) }), _jsx("button", { onClick: handleCamera, className: `w-12 h-12 flex items-center justify-center rounded-full transition-colors ${isCameraOn + ? 'bg-[#2b2d31] text-discord-green hover:bg-[#36373d]' + : 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'}`, title: isCameraOn ? 'Turn Off Camera' : 'Turn On Camera', children: isCameraOn ? (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) })) : (_jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }), _jsx("line", { x1: "2", y1: "2", x2: "22", y2: "22", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] })) }), _jsx("button", { onClick: handleScreenShare, className: `w-12 h-12 flex items-center justify-center rounded-full transition-colors ${isScreenSharing + ? 'bg-[#2b2d31] text-discord-green hover:bg-[#36373d]' + : 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'}`, title: isScreenSharing ? 'Stop Sharing' : 'Share Screen', children: _jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" }), _jsx("path", { d: "M15 11L11 14V12H9V10H11V8L15 11Z" })] }) }), _jsxs("div", { className: "relative", children: [_jsx("button", { onClick: () => setQualityOpen(!qualityOpen), className: `w-12 h-12 flex items-center justify-center rounded-full transition-colors ${qualityOpen + ? 'bg-[#2b2d31] text-discord-text-primary' + : 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'}`, title: "Video Quality", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) }), _jsx(VideoQualityPopover, { open: qualityOpen, onClose: () => setQualityOpen(false) })] }), _jsx("div", { className: "w-[1px] h-8 bg-[#3f4147] mx-1" }), _jsx("button", { onClick: toggleVoiceChat, className: `w-12 h-12 flex items-center justify-center rounded-full transition-colors ${voiceChatOpen + ? 'bg-[#2b2d31] text-discord-text-primary' + : 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'}`, title: "Toggle Chat", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 9h12v2H6V9zm8 5H6v-2h8v2zm4-6H6V6h12v2z" }) }) }), _jsx("button", { onClick: handleFullscreen, className: `w-12 h-12 flex items-center justify-center rounded-full transition-colors ${voiceFullscreen + ? 'bg-[#2b2d31] text-discord-text-primary' + : 'bg-[#2b2d31] text-discord-text-secondary hover:bg-[#36373d] hover:text-discord-text-primary'}`, title: voiceFullscreen ? 'Exit Fullscreen (Esc)' : 'Fullscreen', children: voiceFullscreen ? (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" }) })) : (_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" }) })) }), _jsx("div", { className: "w-[1px] h-8 bg-[#3f4147] mx-1" }), _jsx("button", { onClick: handleDisconnect, className: "w-12 h-12 flex items-center justify-center rounded-full bg-discord-red hover:bg-discord-red-hover transition-colors text-white", title: "Disconnect", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" }) }) })] })); +} diff --git a/packages/web/src/components/voice/VoiceControlBar.tsx b/packages/web/src/components/voice/VoiceControlBar.tsx new file mode 100644 index 00000000..295c70a0 --- /dev/null +++ b/packages/web/src/components/voice/VoiceControlBar.tsx @@ -0,0 +1,291 @@ +import React, { useEffect, useState } from 'react'; +import { useVoiceStore } from '../../stores/voiceStore'; +import { useUIStore } from '../../stores/uiStore'; +import { getActiveRoom } from '../../hooks/useLiveKit'; +import { wsSend } from '../../hooks/useWebSocket'; +import { VideoQualityPopover } from './VideoQualityPopover'; +import { VideoPresets, VideoPreset } from 'livekit-client'; + +const QUALITY_MAP: Record = { + '1080p60': new VideoPreset(1920, 1080, 15_000_000, 60), + '1080p': new VideoPreset(1920, 1080, 8_000_000, 30), + '720p60': new VideoPreset(1280, 720, 8_000_000, 60), + '720p': new VideoPreset(1280, 720, 5_000_000, 30), + '540p': new VideoPreset(960, 540, 2_000_000, 30), + '360p': new VideoPreset(640, 360, 1_000_000, 30), +}; + +export function VoiceControlBar() { + const isMuted = useVoiceStore((s) => s.isMuted); + const isDeafened = useVoiceStore((s) => s.isDeafened); + const isCameraOn = useVoiceStore((s) => s.isCameraOn); + const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); + const toggleMic = useVoiceStore((s) => s.toggleMic); + const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); + const toggleCamera = useVoiceStore((s) => s.toggleCamera); + const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare); + const voiceChatOpen = useUIStore((s) => s.voiceChatOpen); + const toggleVoiceChat = useUIStore((s) => s.toggleVoiceChat); + const voiceFullscreen = useUIStore((s) => s.voiceFullscreen); + const toggleVoiceFullscreen = useUIStore((s) => s.toggleVoiceFullscreen); + const [qualityOpen, setQualityOpen] = useState(false); + + const handleMute = React.useCallback(async () => { + const room = getActiveRoom(); + if (room) { + try { + await room.localParticipant.setMicrophoneEnabled(isMuted); + } catch (err) { + console.error('[VoiceControlBar] Failed to toggle mic:', err); + } + } + toggleMic(); + }, [isMuted, toggleMic]); + + const handleDeafen = React.useCallback(async () => { + const room = getActiveRoom(); + if (room) { + try { + const willDeafen = !isDeafened; + if (willDeafen) { + await room.localParticipant.setMicrophoneEnabled(false); + room.remoteParticipants.forEach((p) => p.setVolume(0)); + if (!isMuted) toggleMic(); + } else { + const outputVolume = useVoiceStore.getState().outputVolume; + room.remoteParticipants.forEach((p) => p.setVolume(outputVolume / 100)); + await room.localParticipant.setMicrophoneEnabled(true); + if (isMuted) toggleMic(); + } + } catch (err) { + console.error('[VoiceControlBar] Failed to toggle deafen:', err); + } + } + toggleDeafen(); + }, [isDeafened, isMuted, toggleDeafen, toggleMic]); + + const handleCamera = async () => { + const room = getActiveRoom(); + if (!room) return; + try { + const willEnable = !isCameraOn; + if (willEnable) { + const videoQuality = useVoiceStore.getState().videoQuality; + const preset = QUALITY_MAP[videoQuality]; + if (preset) { + await room.localParticipant.setCameraEnabled(true, + { resolution: preset.resolution }, + { + videoEncoding: preset.encoding, + simulcast: videoQuality === '1080p' || videoQuality === '720p' + } + ); + } else { + await room.localParticipant.setCameraEnabled(true); + } + } else { + await room.localParticipant.setCameraEnabled(false); + } + toggleCamera(); + } catch (err) { + console.error('[VoiceControlBar] Failed to toggle camera:', err); + } + }; + + const handleScreenShare = async () => { + const room = getActiveRoom(); + if (!room) return; + try { + await room.localParticipant.setScreenShareEnabled(!isScreenSharing); + toggleScreenShare(); + } catch (err) { + console.error('[VoiceControlBar] Failed to toggle screen share:', err); + } + }; + + const handleDisconnect = () => { + wsSend({ type: 'voice_leave' }); + useVoiceStore.getState().leaveVoice(); + if (voiceFullscreen) { + useUIStore.getState().setVoiceFullscreen(false); + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => {}); + } + } + }; + + const handleFullscreen = () => { + if (!voiceFullscreen) { + document.documentElement.requestFullscreen?.().catch(() => {}); + } else { + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => {}); + } + } + toggleVoiceFullscreen(); + }; + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + if (e.key === 'm' || e.key === 'M') { + e.preventDefault(); + handleMute(); + } else if (e.key === 'd' || e.key === 'D') { + e.preventDefault(); + handleDeafen(); + } else if (e.key === 'Escape' && voiceFullscreen) { + useUIStore.getState().setVoiceFullscreen(false); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleMute, handleDeafen, voiceFullscreen]); + + return ( +
+ {/* Mute */} + + + {/* Deafen */} + + + {/* Camera */} + + + {/* Screen Share */} + + + {/* Video Quality */} +
+ + setQualityOpen(false)} /> +
+ + {/* Separator */} +
+ + {/* Chat Toggle */} + + + {/* Fullscreen Toggle */} + + + {/* Separator */} +
+ + {/* Disconnect */} + +
+ ); +} diff --git a/packages/web/src/components/voice/VoiceControls.js b/packages/web/src/components/voice/VoiceControls.js index f1911987..56ef9897 100644 --- a/packages/web/src/components/voice/VoiceControls.js +++ b/packages/web/src/components/voice/VoiceControls.js @@ -5,10 +5,14 @@ import { getActiveRoom } from '../../hooks/useLiveKit'; import { wsSend } from '../../hooks/useWebSocket'; export function VoiceControls() { const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); + const isMuted = useVoiceStore((s) => s.isMuted); + const isDeafened = useVoiceStore((s) => s.isDeafened); const isCameraOn = useVoiceStore((s) => s.isCameraOn); const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); const toggleCamera = useVoiceStore((s) => s.toggleCamera); const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare); + const toggleMic = useVoiceStore((s) => s.toggleMic); + const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); const connectionError = useVoiceStore((s) => s.connectionError); const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected); const channels = useServerStore((s) => s.channels); @@ -16,12 +20,54 @@ export function VoiceControls() { return null; const channel = channels.find(c => c.id === currentVoiceChannelId); const channelName = channel?.name ?? 'Voice Channel'; + const handleMute = async () => { + const room = getActiveRoom(); + if (room) { + try { + await room.localParticipant.setMicrophoneEnabled(isMuted); + } + catch (err) { + console.error('[VoiceControls] Failed to toggle mic:', err); + } + } + toggleMic(); + }; + const handleDeafen = async () => { + const room = getActiveRoom(); + if (room) { + try { + const willDeafen = !isDeafened; + if (willDeafen) { + // Deafen: mute mic + mute all remote audio + await room.localParticipant.setMicrophoneEnabled(false); + room.remoteParticipants.forEach((p) => { + p.setVolume(0); + }); + if (!isMuted) + toggleMic(); // Also mute mic when deafening + } + else { + // Undeafen: restore remote audio, unmute mic + const outputVolume = useVoiceStore.getState().outputVolume; + const scaled = outputVolume / 100; + room.remoteParticipants.forEach((p) => { + p.setVolume(scaled); + }); + await room.localParticipant.setMicrophoneEnabled(true); + if (isMuted) + toggleMic(); // Unmute mic when undeafening + } + } + catch (err) { + console.error('[VoiceControls] Failed to toggle deafen:', err); + } + } + toggleDeafen(); + }; const handleCamera = async () => { const room = getActiveRoom(); - if (!room) { - console.warn('[VoiceControls] handleCamera: no active room'); + if (!room) return; - } try { await room.localParticipant.setCameraEnabled(!isCameraOn); toggleCamera(); @@ -32,10 +78,8 @@ export function VoiceControls() { }; const handleScreenShare = async () => { const room = getActiveRoom(); - if (!room) { - console.warn('[VoiceControls] handleScreenShare: no active room'); + if (!room) return; - } try { await room.localParticipant.setScreenShareEnabled(!isScreenSharing); toggleScreenShare(); @@ -48,5 +92,23 @@ export function VoiceControls() { wsSend({ type: 'voice_leave' }); useVoiceStore.getState().leaveVoice(); }; - return (_jsxs("div", { className: "bg-discord-bg-secondary border-t border-discord-bg-tertiary px-2 py-[10px]", children: [_jsxs("div", { className: "flex items-center justify-between mb-1", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: `text-[13px] font-semibold leading-[18px] ${connectionError ? 'text-discord-red' : isLiveKitConnected ? 'text-discord-green' : 'text-discord-yellow'}`, children: connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...' }), _jsx("div", { className: "text-[13px] text-discord-text-muted truncate leading-[18px]", children: connectionError ? connectionError : channelName })] }), _jsx("button", { onClick: handleDisconnect, className: "w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover rounded-[4px] transition-colors flex-shrink-0", title: "Disconnect", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" }) }) })] }), _jsxs("div", { className: "flex items-center justify-center gap-1", children: [_jsx("button", { onClick: handleCamera, className: `w-8 h-8 flex items-center justify-center rounded-[4px] transition-colors ${isCameraOn ? 'text-discord-green bg-discord-green/10 hover:bg-discord-green/20' : 'text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover'}`, title: isCameraOn ? 'Turn Off Camera' : 'Turn On Camera', children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) }) }), _jsx("button", { onClick: handleScreenShare, className: `w-8 h-8 flex items-center justify-center rounded-[4px] transition-colors ${isScreenSharing ? 'text-discord-green bg-discord-green/10 hover:bg-discord-green/20' : 'text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover'}`, title: isScreenSharing ? 'Stop Sharing' : 'Share Screen', children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" }) }) })] })] })); + const statusColor = connectionError + ? 'text-discord-red' + : isLiveKitConnected + ? 'text-discord-green' + : 'text-discord-yellow'; + const statusBgColor = connectionError + ? 'bg-discord-red/20' + : isLiveKitConnected + ? 'bg-discord-green/20' + : 'bg-discord-yellow/20'; + return (_jsxs("div", { className: "bg-[#232428] border-t border-discord-bg-tertiary", children: [_jsxs("div", { className: "flex items-center gap-2 px-2 pt-[10px] pb-1", children: [_jsx("div", { className: `w-8 h-8 rounded-lg ${statusBgColor} flex items-center justify-center flex-shrink-0`, children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: statusColor, children: _jsx("path", { d: "M1.5 21.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zM3.14 15.75a.75.75 0 01-.09-1.06A8.46 8.46 0 0112 11a8.46 8.46 0 018.95 3.69.75.75 0 01-1.15.97A6.96 6.96 0 0012 12.5a6.96 6.96 0 00-7.8 3.16.75.75 0 01-1.06.09zM6.37 18.3a.75.75 0 01-.08-1.06A5.46 5.46 0 0112 15a5.46 5.46 0 015.71 2.24.75.75 0 01-1.14.97A3.96 3.96 0 0012 16.5a3.96 3.96 0 00-4.57 1.71.75.75 0 01-1.06.09z" }) }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: `text-[13px] font-semibold leading-[18px] ${statusColor}`, children: connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...' }), _jsx("div", { className: "text-[12px] text-discord-channels-default truncate leading-[16px]", children: connectionError ? connectionError : channelName })] }), _jsxs("div", { className: "flex items-center gap-0.5 flex-shrink-0", children: [_jsx("button", { className: "w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded", title: "Connection Info", children: _jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M2 20h2V8H2v12zm5 0h2V4H7v16zm5 0h2v-8h-2v8zm5 0h2V12h-2v8z" }) }) }), _jsx("button", { onClick: handleDisconnect, className: "w-7 h-7 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded", title: "Disconnect", children: _jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" }) }) })] })] }), _jsxs("div", { className: "flex items-center gap-1 px-2 pb-[10px] pt-1", children: [_jsx("button", { onClick: handleMute, className: `flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors ${isMuted || isDeafened + ? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30' + : 'bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary'}`, title: isMuted ? 'Unmute' : 'Mute', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" }), _jsx("path", { d: "M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" }), (isMuted || isDeafened) && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] }) }), _jsx("button", { onClick: handleDeafen, className: `flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors ${isDeafened + ? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30' + : 'bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary'}`, title: isDeafened ? 'Undeafen' : 'Deafen', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }), isDeafened && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] }) }), _jsx("button", { onClick: handleCamera, className: `flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors ${isCameraOn + ? 'bg-discord-bg-tertiary text-discord-green hover:bg-discord-bg-tertiary/80' + : 'bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary'}`, title: isCameraOn ? 'Turn Off Camera' : 'Turn On Camera', children: isCameraOn ? (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) })) : (_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }), _jsx("line", { x1: "2", y1: "2", x2: "22", y2: "22", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] })) }), _jsx("button", { onClick: handleScreenShare, className: `flex-1 h-[34px] flex items-center justify-center rounded-[4px] transition-colors ${isScreenSharing + ? 'bg-discord-bg-tertiary text-discord-green hover:bg-discord-bg-tertiary/80' + : 'bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary'}`, title: isScreenSharing ? 'Stop Sharing' : 'Share Screen', children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" }), _jsx("path", { d: "M15 11L11 14V12H9V10H11V8L15 11Z" })] }) })] })] })); } diff --git a/packages/web/src/components/voice/VoiceControls.tsx b/packages/web/src/components/voice/VoiceControls.tsx index 3aefe97b..ccf55a04 100644 --- a/packages/web/src/components/voice/VoiceControls.tsx +++ b/packages/web/src/components/voice/VoiceControls.tsx @@ -6,10 +6,14 @@ import { wsSend } from '../../hooks/useWebSocket'; export function VoiceControls() { const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); + const isMuted = useVoiceStore((s) => s.isMuted); + const isDeafened = useVoiceStore((s) => s.isDeafened); const isCameraOn = useVoiceStore((s) => s.isCameraOn); const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); const toggleCamera = useVoiceStore((s) => s.toggleCamera); const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare); + const toggleMic = useVoiceStore((s) => s.toggleMic); + const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); const connectionError = useVoiceStore((s) => s.connectionError); const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected); const channels = useServerStore((s) => s.channels); @@ -19,6 +23,47 @@ export function VoiceControls() { const channel = channels.find(c => c.id === currentVoiceChannelId); const channelName = channel?.name ?? 'Voice Channel'; + const handleMute = async () => { + const room = getActiveRoom(); + if (room) { + try { + await room.localParticipant.setMicrophoneEnabled(isMuted); + } catch (err) { + console.error('[VoiceControls] Failed to toggle mic:', err); + } + } + toggleMic(); + }; + + const handleDeafen = async () => { + const room = getActiveRoom(); + if (room) { + try { + const willDeafen = !isDeafened; + if (willDeafen) { + // Deafen: mute mic + mute all remote audio + await room.localParticipant.setMicrophoneEnabled(false); + room.remoteParticipants.forEach((p) => { + p.setVolume(0); + }); + if (!isMuted) toggleMic(); // Also mute mic when deafening + } else { + // Undeafen: restore remote audio, unmute mic + const outputVolume = useVoiceStore.getState().outputVolume; + const scaled = outputVolume / 100; + room.remoteParticipants.forEach((p) => { + p.setVolume(scaled); + }); + await room.localParticipant.setMicrophoneEnabled(true); + if (isMuted) toggleMic(); // Unmute mic when undeafening + } + } catch (err) { + console.error('[VoiceControls] Failed to toggle deafen:', err); + } + } + toggleDeafen(); + }; + const handleCamera = async () => { const room = getActiveRoom(); if (!room) return; @@ -60,16 +105,14 @@ export function VoiceControls() { return (
- {/* Row 1: Signal icon + status text + right icons */} + {/* Row 1: Signal icon + status text + disconnect */}
- {/* Signal icon */}
- {/* Status text */}
{connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'} @@ -79,15 +122,12 @@ export function VoiceControls() {
- {/* Right icons */}
- {/* Signal quality */} - {/* Disconnect */}
- {/* Row 2: Media control buttons */} + {/* Row 2: Mute, Deafen, Camera, Screen Share */}
+ {/* Mute */} + + + {/* Deafen */} + + {/* Camera */} - - {/* Noise Suppression */} - - - {/* Activities */} -
); diff --git a/packages/web/src/components/voice/VoiceGrid.js b/packages/web/src/components/voice/VoiceGrid.js index aecb5e2d..c337283d 100644 --- a/packages/web/src/components/voice/VoiceGrid.js +++ b/packages/web/src/components/voice/VoiceGrid.js @@ -1,9 +1,21 @@ -import { jsx as _jsx } from "react/jsx-runtime"; +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { VoiceUser } from './VoiceUser'; +import { useVoiceStore } from '../../stores/voiceStore'; export function VoiceGrid({ participants }) { + const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId); + const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant); if (participants.length === 0) { return (_jsx("div", { className: "flex-1 flex items-center justify-center text-discord-text-muted", children: _jsx("p", { children: "No one is in this voice channel" }) })); } + const focusedParticipant = focusedParticipantId + ? participants.find(p => p.identity === focusedParticipantId) + : null; + // Focus mode: one large tile + sidebar strip + if (focusedParticipant) { + const otherParticipants = participants.filter(p => p.identity !== focusedParticipantId); + return (_jsxs("div", { className: "flex-1 flex overflow-hidden", children: [_jsx("div", { className: "flex-1 p-2", onDoubleClick: () => setFocusedParticipant(null), children: _jsx(VoiceUser, { participant: focusedParticipant, large: true }) }), otherParticipants.length > 0 && (_jsx("div", { className: "w-[200px] flex-shrink-0 overflow-y-auto p-2 space-y-2", children: otherParticipants.map((p) => (_jsx("div", { onClick: () => setFocusedParticipant(p.identity), className: "cursor-pointer", children: _jsx(VoiceUser, { participant: p }) }, p.identity))) }))] })); + } + // Default grid mode const gridClass = (() => { if (participants.length === 1) return 'grid-cols-1 max-w-2xl mx-auto'; @@ -11,7 +23,9 @@ export function VoiceGrid({ participants }) { return 'grid-cols-2 max-w-4xl mx-auto'; if (participants.length <= 4) return 'grid-cols-2'; - return 'grid-cols-3'; + if (participants.length <= 9) + return 'grid-cols-3'; + return 'grid-cols-4'; })(); - return (_jsx("div", { className: "flex-1 p-4 overflow-auto", children: _jsx("div", { className: `grid ${gridClass} gap-2 h-full`, children: participants.map((p) => (_jsx(VoiceUser, { participant: p }, p.identity))) }) })); + return (_jsx("div", { className: "flex-1 p-4 overflow-auto", children: _jsx("div", { className: `grid ${gridClass} gap-2 h-full`, children: participants.map((p) => (_jsx("div", { onClick: () => setFocusedParticipant(p.identity), className: "cursor-pointer", children: _jsx(VoiceUser, { participant: p }) }, p.identity))) }) })); } diff --git a/packages/web/src/components/voice/VoiceGrid.tsx b/packages/web/src/components/voice/VoiceGrid.tsx index fe730d02..75065aef 100644 --- a/packages/web/src/components/voice/VoiceGrid.tsx +++ b/packages/web/src/components/voice/VoiceGrid.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { VoiceUser } from './VoiceUser'; +import { useVoiceStore } from '../../stores/voiceStore'; import type { ParticipantInfo } from '../../hooks/useLiveKit'; interface VoiceGridProps { @@ -7,6 +8,9 @@ interface VoiceGridProps { } export function VoiceGrid({ participants }: VoiceGridProps) { + const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId); + const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant); + if (participants.length === 0) { return (
@@ -15,18 +19,61 @@ export function VoiceGrid({ participants }: VoiceGridProps) { ); } + const focusedParticipant = focusedParticipantId + ? participants.find(p => p.identity === focusedParticipantId) + : null; + + // Focus mode: one large tile + sidebar strip + if (focusedParticipant) { + const otherParticipants = participants.filter(p => p.identity !== focusedParticipantId); + return ( +
+ {/* Main focused view */} +
setFocusedParticipant(null)} + > + +
+ + {/* Side strip of other participants */} + {otherParticipants.length > 0 && ( +
+ {otherParticipants.map((p) => ( +
setFocusedParticipant(p.identity)} + className="cursor-pointer" + > + +
+ ))} +
+ )} +
+ ); + } + + // Default grid mode const gridClass = (() => { if (participants.length === 1) return 'grid-cols-1 max-w-2xl mx-auto'; if (participants.length === 2) return 'grid-cols-2 max-w-4xl mx-auto'; if (participants.length <= 4) return 'grid-cols-2'; - return 'grid-cols-3'; + if (participants.length <= 9) return 'grid-cols-3'; + return 'grid-cols-4'; })(); return (
{participants.map((p) => ( - +
setFocusedParticipant(p.identity)} + className="cursor-pointer" + > + +
))}
diff --git a/packages/web/src/components/voice/VoiceUser.js b/packages/web/src/components/voice/VoiceUser.js index fd046701..4725a594 100644 --- a/packages/web/src/components/voice/VoiceUser.js +++ b/packages/web/src/components/voice/VoiceUser.js @@ -1,11 +1,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; -import { useRef, useEffect } from 'react'; +import { useRef, useEffect, useState, useCallback } from 'react'; import { Avatar } from '../ui/Avatar'; import { useVoiceStore } from '../../stores/voiceStore'; -export function VoiceUser({ participant }) { +export function VoiceUser({ participant, large }) { const videoRef = useRef(null); const audioRef = useRef(null); const isDeafened = useVoiceStore((s) => s.isDeafened); + const outputVolume = useVoiceStore((s) => s.outputVolume); + const participantVolumes = useVoiceStore((s) => s.participantVolumes); + const perUserVolume = participantVolumes.get(participant.userId) ?? 100; useEffect(() => { const videoEl = videoRef.current; if (!videoEl) @@ -26,14 +29,42 @@ export function VoiceUser({ participant }) { const stream = new MediaStream([participant.audioTrack]); audioEl.srcObject = stream; }, [participant.audioTrack]); - // Mute remote audio when deafened + // Apply volume: combine outputVolume and per-participant volume, or mute if deafened useEffect(() => { const audioEl = audioRef.current; - if (audioEl) { - audioEl.muted = isDeafened; + if (!audioEl) + return; + if (isDeafened) { + audioEl.volume = 0; } - }, [isDeafened]); + else { + // Both are 0-200 scale with 100 = default. Combine as fractions. + const combined = (outputVolume / 100) * (perUserVolume / 100); + audioEl.volume = Math.min(Math.max(combined, 0), 1); + } + audioEl.muted = isDeafened; + }, [isDeafened, outputVolume, perUserVolume]); const hasVideo = participant.isCameraOn || participant.isScreenSharing; const isLocal = participant.isLocal; - return (_jsxs("div", { className: `relative bg-discord-bg-secondary rounded-xl overflow-hidden flex items-center justify-center ${participant.isSpeaking ? 'ring-2 ring-discord-green' : ''}`, style: { aspectRatio: '16/9', minHeight: '200px' }, children: [!isLocal && _jsx("audio", { ref: audioRef, autoPlay: true }), hasVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: isLocal, className: "w-full h-full object-cover" })) : (_jsx(Avatar, { src: null, name: participant.username, size: 80 })), _jsx("div", { className: "absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-sm font-medium text-white", children: participant.username }), _jsx("div", { className: "flex items-center gap-1", children: participant.isMuted && (_jsx("div", { className: "w-5 h-5 bg-discord-red/80 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: [_jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) })) })] }) }), participant.isSpeaking && (_jsx("div", { className: "absolute inset-0 rounded-xl ring-2 ring-discord-green animate-pulse pointer-events-none" }))] })); + // Volume context menu + const [volumeMenu, setVolumeMenu] = useState(null); + const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume); + const handleContextMenu = useCallback((e) => { + if (isLocal) + return; // No volume control for self + e.preventDefault(); + setVolumeMenu({ x: e.clientX, y: e.clientY }); + }, [isLocal]); + // Close volume menu on click outside + useEffect(() => { + if (!volumeMenu) + return; + const close = () => setVolumeMenu(null); + window.addEventListener('click', close); + return () => window.removeEventListener('click', close); + }, [volumeMenu]); + return (_jsxs("div", { className: `relative bg-discord-bg-secondary rounded-xl overflow-hidden flex items-center justify-center transition-all ${participant.isSpeaking ? 'ring-[3px] ring-discord-green' : 'ring-1 ring-transparent'} ${large ? 'h-full' : ''}`, style: large ? undefined : { aspectRatio: '16/9', minHeight: '140px' }, onContextMenu: handleContextMenu, children: [!isLocal && _jsx("audio", { ref: audioRef, autoPlay: true }), hasVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: isLocal, className: `w-full h-full ${large ? 'object-contain' : 'object-cover'}`, style: { + imageRendering: 'crisp-edges', + WebkitFontSmoothing: 'antialiased' + } })) : (_jsx("div", { className: "flex flex-col items-center justify-center gap-2", children: _jsx(Avatar, { src: null, name: participant.username, size: large ? 100 : 80 }) })), _jsx("div", { className: "absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: `font-medium text-white ${large ? 'text-base' : 'text-sm'}`, children: participant.username }), isLocal && (_jsx("span", { className: "text-[10px] text-white/50 font-medium", children: "(you)" }))] }), _jsxs("div", { className: "flex items-center gap-1", children: [participant.isMuted && (_jsx("div", { className: "w-5 h-5 bg-discord-red/80 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: [_jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) })), participant.isScreenSharing && (_jsx("div", { className: "w-5 h-5 bg-discord-blurple/80 rounded-full flex items-center justify-center", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: _jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20Z" }) }) }))] })] }) }), volumeMenu && !isLocal && (_jsxs("div", { className: "fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-3 min-w-[200px]", style: { left: volumeMenu.x, top: volumeMenu.y }, onClick: (e) => e.stopPropagation(), children: [_jsx("div", { className: "text-xs text-discord-text-muted mb-2 font-medium uppercase tracking-wider", children: "User Volume" }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M3 9v6h4l5 5V4L7 9H3z" }) }), _jsx("input", { type: "range", min: "0", max: "200", value: perUserVolume, onChange: (e) => setParticipantVolume(participant.userId, parseInt(e.target.value)), className: "flex-1 accent-discord-blurple h-1" }), _jsxs("span", { className: "text-xs text-discord-text-secondary min-w-[32px] text-right", children: [perUserVolume, "%"] })] })] }))] })); } diff --git a/packages/web/src/components/voice/VoiceUser.tsx b/packages/web/src/components/voice/VoiceUser.tsx index 02ff68d0..418585d5 100644 --- a/packages/web/src/components/voice/VoiceUser.tsx +++ b/packages/web/src/components/voice/VoiceUser.tsx @@ -1,16 +1,21 @@ -import React, { useRef, useEffect } from 'react'; +import React, { useRef, useEffect, useState, useCallback } from 'react'; import { Avatar } from '../ui/Avatar'; import { useVoiceStore } from '../../stores/voiceStore'; import type { ParticipantInfo } from '../../hooks/useLiveKit'; interface VoiceUserProps { participant: ParticipantInfo; + large?: boolean; } -export function VoiceUser({ participant }: VoiceUserProps) { +export function VoiceUser({ participant, large }: VoiceUserProps) { const videoRef = useRef(null); const audioRef = useRef(null); const isDeafened = useVoiceStore((s) => s.isDeafened); + const outputVolume = useVoiceStore((s) => s.outputVolume); + const participantVolumes = useVoiceStore((s) => s.participantVolumes); + + const perUserVolume = participantVolumes.get(participant.userId) ?? 100; useEffect(() => { const videoEl = videoRef.current; @@ -33,23 +38,48 @@ export function VoiceUser({ participant }: VoiceUserProps) { audioEl.srcObject = stream; }, [participant.audioTrack]); - // Mute remote audio when deafened + // Apply volume: combine outputVolume and per-participant volume, or mute if deafened useEffect(() => { const audioEl = audioRef.current; - if (audioEl) { - audioEl.muted = isDeafened; + if (!audioEl) return; + if (isDeafened) { + audioEl.volume = 0; + } else { + // Both are 0-200 scale with 100 = default. Combine as fractions. + const combined = (outputVolume / 100) * (perUserVolume / 100); + audioEl.volume = Math.min(Math.max(combined, 0), 1); } - }, [isDeafened]); + audioEl.muted = isDeafened; + }, [isDeafened, outputVolume, perUserVolume]); const hasVideo = participant.isCameraOn || participant.isScreenSharing; const isLocal = participant.isLocal; + // Volume context menu + const [volumeMenu, setVolumeMenu] = useState<{ x: number; y: number } | null>(null); + const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume); + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + if (isLocal) return; // No volume control for self + e.preventDefault(); + setVolumeMenu({ x: e.clientX, y: e.clientY }); + }, [isLocal]); + + // Close volume menu on click outside + useEffect(() => { + if (!volumeMenu) return; + const close = () => setVolumeMenu(null); + window.addEventListener('click', close); + return () => window.removeEventListener('click', close); + }, [volumeMenu]); + return (
{/* Audio element for remote participants */} {!isLocal &&