feat: Optimize WebRTC pipeline for 60fps screen sharing

- Implemented 'Overdrive' logic to force high bitrates on Chrome
- Fixed 'Auto' preset to default to stable 720p60
- Added persistent 'Triple-Kick' hammer to prevent bitrate throttling
- Fixed sidebar connection status sync
- Added comprehensive diagnostic logger
This commit is contained in:
Jannis Braun
2026-02-19 03:34:20 +01:00
parent 435d12e5b8
commit 7ae3e8c687
56 changed files with 3558 additions and 577 deletions
+24 -16
View File
@@ -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<void> {
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<void> {
});
token.addGrant({
room: channelId,
room: roomName,
roomJoin: true,
canPublish: true,
canSubscribe: true,
@@ -41,8 +51,6 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
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`
+155
View File
@@ -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<string, unknown>, userId: string): void
messageId,
});
}
// ─── DM Call Handlers ──────────────────────────────────────────────────────────
function handleDmCallStart(event: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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,
});
}
}
+17
View File
@@ -42,6 +42,8 @@ class ConnectionManager {
private voiceStates: Map<string, Set<string>> = new Map();
// ws → userId (reverse lookup)
private wsToUser: Map<WebSocket, string> = new Map();
// dmChannelId → { callerId, startedAt } — active DM calls
private activeCalls: Map<string, { callerId: string; startedAt: number }> = 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);
+121
View File
@@ -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);
});
+9 -1
View File
@@ -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 ─────────────────────────────────────────────
+1
View File
@@ -113,5 +113,6 @@ export const api = {
},
livekit: {
token: (channelId) => request('POST', '/livekit/token', { channelId }),
dmToken: (dmChannelId) => request('POST', '/livekit/token', { dmChannelId }),
},
};
+2
View File
@@ -177,5 +177,7 @@ export const api = {
livekit: {
token: (channelId: string) =>
request<LiveKitTokenResponse>('POST', '/livekit/token', { channelId }),
dmToken: (dmChannelId: string) =>
request<LiveKitTokenResponse>('POST', '/livekit/token', { dmChannelId }),
},
};
@@ -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');
});
});
});
});
@@ -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" }) }) })] })] })] }));
}
@@ -215,21 +215,24 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
</div>
)}
{/* Emoji button placeholder */}
<button className="p-3 text-discord-text-muted hover:text-discord-text-secondary transition-colors">
{/* GIF button */}
<button className="p-2 text-discord-text-muted hover:text-discord-text-secondary transition-colors" title="GIF">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
<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" />
</svg>
</button>
{/* Send Button */}
<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"
>
{/* Sticker button */}
<button className="p-2 text-discord-text-muted hover:text-discord-text-secondary transition-colors" title="Stickers">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
<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" />
</svg>
</button>
{/* Emoji button */}
<button className="p-2 text-discord-text-muted hover:text-discord-text-secondary transition-colors" title="Emoji">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
</div>
@@ -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" })] }));
}
@@ -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) {
</div>
)}
{!hasMore && (
<div className="px-4 pt-8 pb-4">
<div className="w-[68px] h-[68px] rounded-full bg-discord-bg-accent flex items-center justify-center mb-4 text-white">
<svg width="42" height="42" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</div>
<h3 className="text-[32px] leading-10 font-bold text-discord-text-primary">Welcome to the channel!</h3>
<p className="text-discord-text-secondary text-[16px] mt-2">This is the start of the conversation.</p>
<div className="mt-6 border-b border-discord-modifier-accent" />
</div>
)}
{!hasMore && <WelcomeHeader channelId={channelId} />}
<div className="pb-6">
{messages.map((msg, i) => {
@@ -162,3 +154,47 @@ export function MessageList({ channelId }: MessageListProps) {
</div>
);
}
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 (
<div className="px-4 pt-8 pb-4">
<div className="mb-2">
<Avatar src={otherUser?.avatar} name={displayName} size={80} />
</div>
<h3 className="text-[32px] leading-10 font-bold text-discord-text-primary">{displayName}</h3>
<p className="text-discord-text-secondary text-[14px] mt-1">
This is the beginning of your direct message history with <strong>@{username}</strong>.
</p>
<div className="mt-4">
<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">
Remove Friend
</button>
</div>
<div className="mt-6 border-b border-discord-modifier-accent" />
</div>
);
}
return (
<div className="px-4 pt-8 pb-4">
<div className="w-[68px] h-[68px] rounded-full bg-discord-bg-accent flex items-center justify-center mb-4 text-white">
<svg width="42" height="42" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</div>
<h3 className="text-[32px] leading-10 font-bold text-discord-text-primary">Welcome to the channel!</h3>
<p className="text-discord-text-secondary text-[16px] mt-2">This is the start of the conversation.</p>
<div className="mt-6 border-b border-discord-modifier-accent" />
</div>
);
}
@@ -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 })] }))] }));
}
@@ -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() {
<UserSettingsModal />
<ServerSettingsModal />
<NewDmModal />
<IncomingCallModal />
<ImagePreview />
{/* User Profile Popout */}
File diff suppressed because one or more lines are too long
@@ -77,26 +77,49 @@ export function ChannelSidebar() {
if (!server) {
return (
<div className="w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0 select-none">
<div className="h-12 px-4 flex items-center shadow-header z-10">
<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">
<div className="h-12 px-[10px] flex items-center shadow-header z-10">
<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">
Find or start a conversation
</button>
</div>
<div className="flex-1 overflow-y-auto pt-4 px-2 no-scrollbar">
<div
<div
onClick={handleHomeClick}
className={`flex items-center gap-3 px-2 h-10 rounded-[4px] cursor-pointer mb-0.5 transition-colors group ${
!currentChannelId
? 'bg-discord-modifier-selected text-white'
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'
}`}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className={`${!currentChannelId ? 'text-white' : 'opacity-70 group-hover:opacity-100'}`}>
<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" />
<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'}`}>
<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" />
<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" />
<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" />
</svg>
<span className="font-medium text-[16px]">Friends</span>
</div>
{/* Nitro */}
<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"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 opacity-70 group-hover:opacity-100">
<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)" />
<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" />
</svg>
<span className="font-medium text-[16px]">Nitro</span>
</div>
{/* Shop */}
<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"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 opacity-70 group-hover:opacity-100">
<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" />
</svg>
<span className="font-medium text-[16px]">Shop</span>
</div>
<div className="mt-[18px] px-2 mb-1 flex items-center justify-between group">
<span className="text-[12px] font-bold text-discord-text-muted uppercase tracking-wider">Direct Messages</span>
<button
@@ -133,14 +156,28 @@ export function ChannelSidebar() {
)}
<Avatar src={otherUser.avatar} name={otherUser.displayName ?? otherUser.username} size={32} status={otherUser.status as any} />
<div className="flex-1 min-w-0">
<div className={`text-[16px] truncate ${
<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'
}`}>
{otherUser.displayName ?? otherUser.username}
</div>
{dm.lastMessage && (
<div className="text-[12px] text-discord-channels-default truncate leading-tight mt-0.5">
{dm.lastMessage.content}
</div>
)}
</div>
<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"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
</div>
);
})}
@@ -149,7 +186,10 @@ export function ChannelSidebar() {
)}
</div>
</div>
{/* Voice controls — visible when in a call, even in DM view */}
{currentVoiceChannelId && <VoiceControls />}
{/* User area at bottom */}
{user && (
<UserAreaPanel
@@ -225,7 +265,7 @@ export function ChannelSidebar() {
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 opacity-60">
<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" />
</svg>
<span className={`truncate text-[16px] ${isUnread ? 'font-bold' : 'font-medium'}`}>{channel.name}</span>
<span className={`truncate text-[15px] ${isUnread ? 'font-bold' : 'font-medium'}`}>{channel.name}</span>
</button>
);
})}
File diff suppressed because one or more lines are too long
@@ -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 (
<div className="flex-1 flex flex-col min-w-0 relative">
<DmCallView />
</div>
);
}
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative">
<div className="h-12 px-4 flex items-center shadow-header flex-shrink-0 z-10">
{/* Outgoing call banner */}
{isCallingThisDm && (
<div className="bg-discord-green/10 border-b border-discord-green/20 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-discord-green animate-pulse">
<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" />
</svg>
<span className="text-discord-green text-sm font-medium">Calling {dmName}...</span>
</div>
<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"
>
Cancel
</button>
</div>
)}
<div className="h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 z-10 bg-discord-bg-primary">
<div className="flex items-center gap-2 min-w-0">
<span className="text-discord-text-muted font-bold text-lg">@</span>
{otherUser && (
<Avatar src={otherUser.avatar} name={dmName} size={24} status={dmStatus} />
)}
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted flex-shrink-0">
<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" />
</svg>
<span className="font-bold text-discord-text-primary truncate">{dmName}</span>
{otherUser?.status && otherUser.status !== 'offline' && (
<span className="text-xs text-discord-text-muted capitalize">{otherUser.status}</span>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Voice Call */}
<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"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
{/* Video Call */}
<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"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
{/* Pinned Messages */}
<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">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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)" />
<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" />
</svg>
</button>
{/* Add Friends to DM */}
<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">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
{/* Divider */}
<div className="w-[1px] h-6 bg-discord-modifier-accent mx-1" />
{/* Search */}
<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">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
{/* Inbox */}
<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">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
{/* Help */}
<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">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
</div>
</div>
<MessageList channelId={currentChannelId} />
@@ -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 (
<div className="flex-1 flex flex-col bg-discord-bg-primary">
<div className="h-12 px-4 flex items-center justify-between shadow-header">
<div className="flex items-center gap-2">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
<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" />
</svg>
<span className="font-bold text-discord-text-primary">{channel.name}</span>
{isInThisChannel && (
<span className="text-xs text-discord-green font-medium ml-2">Connected</span>
)}
// Not connected — show "Join Voice" prompt
if (!isInThisChannel) {
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary">
<div className="h-12 px-4 flex items-center justify-between shadow-header">
<div className="flex items-center gap-2">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
<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" />
</svg>
<span className="font-bold text-discord-text-primary">{channel.name}</span>
</div>
</div>
</div>
{isInThisChannel ? (
<VoiceGrid participants={participants} />
) : (
<div className="flex-1 flex flex-col items-center justify-center gap-6">
<div className="text-center">
<svg width="80" height="80" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted mx-auto mb-4 opacity-40">
@@ -115,9 +211,38 @@ export function MainContent() {
Join Voice
</button>
</div>
)}
</div>
);
}
// Connected — full voice view with grid + chat panel + control bar
const voiceView = (
<div className={`flex-1 flex flex-col bg-[#111214] min-w-0 ${voiceFullscreen ? 'fixed inset-0 z-50' : ''}`}>
{/* Voice header */}
<div className="h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 bg-[#111214]">
<div className="flex items-center gap-2">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
<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" />
</svg>
<span className="font-bold text-discord-text-primary">{channel.name}</span>
<span className="text-xs text-discord-green font-medium ml-2">Connected</span>
</div>
</div>
{/* Main content: grid + optional chat */}
<div className="flex-1 flex overflow-hidden">
<VoiceGrid participants={participants} />
{voiceChatOpen && (
<VoiceChatPanel channelId={currentChannelId} channelName={channel.name} />
)}
</div>
{/* Control bar at bottom */}
<VoiceControlBar />
</div>
);
return voiceView;
}
// Text channel view
@@ -137,10 +262,29 @@ export function MainContent() {
</>
)}
</div>
<div className="flex items-center gap-4 flex-shrink-0">
<div className="flex items-center gap-1 flex-shrink-0">
{/* Threads */}
<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">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
{/* Notification Settings */}
<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">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
{/* Pinned Messages */}
<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">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
{/* Member List Toggle */}
<button
onClick={toggleMemberList}
className={`p-1 transition-colors ${
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"
@@ -149,6 +293,26 @@ export function MainContent() {
<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" />
</svg>
</button>
{/* Divider */}
<div className="w-[1px] h-6 bg-discord-modifier-accent mx-1" />
{/* Search */}
<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">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
{/* Inbox */}
<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">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
{/* Help */}
<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">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
</div>
</div>
@@ -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))] }))] }) }));
}
@@ -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<string, number> = { owner: 0, admin: 1, member: 2 };
const ROLE_LABELS: Record<string, string> = { 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<string, MemberWithUser[]>();
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<string, string> = {
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 (
<div
key={member.userId}
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"
>
<Avatar
src={member.user.avatar}
name={displayName}
size={32}
status={isOffline ? 'offline' : member.user.status}
className={isOffline ? 'opacity-60' : undefined}
/>
<div className="flex-1 min-w-0">
<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)}
>
{displayName}
</div>
{!isOffline && member.user.customStatus && (
<div className="text-[12px] text-discord-text-muted truncate">{member.user.customStatus}</div>
)}
</div>
</div>
);
};
return (
<div className="w-60 bg-discord-bg-members flex-shrink-0 overflow-y-auto select-none no-scrollbar">
<div className="p-3">
{/* Online */}
{onlineMembers.length > 0 && (
<div className="mb-4">
<h3 className="text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1">
Online {onlineMembers.length}
{/* Role-based groups */}
{roleGroups.map(([role, groupMembers]) => (
<div key={role} className="mb-4">
<h3 className="text-[12px] font-bold text-discord-text-muted uppercase tracking-wider px-2 mb-1">
{ROLE_LABELS[role] ?? role.toUpperCase()} {groupMembers.length}
</h3>
{onlineMembers.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (
<div
key={member.userId}
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"
>
<Avatar
src={member.user.avatar}
name={displayName}
size={32}
status={member.user.status}
/>
<div className="flex-1 min-w-0">
<div
className={`text-[15px] font-medium truncate ${!getMemberColor(member) ? (roleColors[member.role] ?? 'text-discord-text-primary') : ''}`}
style={getMemberColor(member)}
>
{displayName}
</div>
{member.user.customStatus && (
<div className="text-[12px] text-discord-text-muted truncate">{member.user.customStatus}</div>
)}
</div>
</div>
);
})}
{groupMembers.map((m) => renderMember(m))}
</div>
)}
))}
{/* Offline */}
{offlineMembers.length > 0 && (
<div>
<h3 className="text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1">
Offline {offlineMembers.length}
<h3 className="text-[12px] font-bold text-discord-text-muted uppercase tracking-wider px-2 mb-1">
OFFLINE {offlineMembers.length}
</h3>
{offlineMembers.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (
<div
key={member.userId}
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"
>
<Avatar
src={member.user.avatar}
name={displayName}
size={32}
status="offline"
className="opacity-60"
/>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-medium truncate text-discord-text-muted">
{displayName}
</div>
</div>
</div>
);
})}
{offlineMembers.map((m) => renderMember(m, true))}
</div>
)}
</div>
@@ -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" })] }));
}
@@ -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,
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
) : actionType === 'explore' ? (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
) : (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" />
@@ -171,6 +175,15 @@ export function ServerSidebar() {
type="action"
actionType="join"
/>
<SidebarItem
id="explore"
name="Explore Discoverable Servers"
active={false}
onClick={() => {}}
type="action"
actionType="explore"
/>
</nav>
);
}
@@ -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();
});
});
@@ -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();
});
});
});
@@ -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)))] })] }) }));
}
@@ -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}`);
}
File diff suppressed because one or more lines are too long
@@ -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<string, any> = {
'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 (
<div className="flex-1 flex flex-col bg-[#111214] min-w-0">
{/* Header */}
<div className="h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0 bg-[#111214]">
<div className="flex items-center gap-2">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-green">
<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" />
</svg>
<span className="font-bold text-discord-text-primary">{otherName}</span>
<span className="text-xs text-discord-green font-medium ml-2">In Call</span>
</div>
</div>
{/* Main call area - 1-on-1 layout */}
<div className="flex-1 flex items-center justify-center gap-8 p-8">
{/* Remote participant (or waiting) */}
<div className="flex flex-col items-center gap-4">
{remoteParticipant?.videoTrack ? (
<div className="w-[360px] h-[270px] rounded-xl overflow-hidden bg-[#2b2d31] relative">
<video
id={`dm-video-${remoteParticipant.userId}`}
autoPlay
playsInline
muted={false}
className="w-full h-full object-cover"
/>
<div className="absolute bottom-2 left-2 px-2 py-1 bg-black/60 rounded text-xs text-white">
{otherName}
</div>
</div>
) : (
<div className="w-[200px] h-[200px] rounded-full bg-[#2b2d31] flex items-center justify-center relative">
<div className="w-24 h-24 rounded-full bg-discord-blurple flex items-center justify-center text-white text-4xl font-bold">
{otherName.charAt(0).toUpperCase()}
</div>
{remoteParticipant?.isSpeaking && (
<div className="absolute inset-0 rounded-full ring-[3px] ring-discord-green" />
)}
</div>
)}
<span className="text-discord-text-secondary text-sm font-medium">
{remoteParticipant ? otherName : 'Connecting...'}
</span>
{remoteParticipant?.isMuted && (
<span className="text-discord-text-muted text-xs flex items-center gap-1">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
Muted
</span>
)}
</div>
{/* Local participant */}
<div className="flex flex-col items-center gap-4">
{localParticipant?.videoTrack ? (
<div className="w-[360px] h-[270px] rounded-xl overflow-hidden bg-[#2b2d31] relative">
<video
id={`dm-video-${localParticipant.userId}`}
autoPlay
playsInline
muted
className="w-full h-full object-cover mirror"
style={{ transform: 'scaleX(-1)' }}
/>
<div className="absolute bottom-2 left-2 px-2 py-1 bg-black/60 rounded text-xs text-white">
You
</div>
</div>
) : (
<div className="w-[200px] h-[200px] rounded-full bg-[#2b2d31] flex items-center justify-center relative">
<div className="w-24 h-24 rounded-full bg-discord-blurple flex items-center justify-center text-white text-4xl font-bold">
{(authUser?.displayName ?? authUser?.username ?? 'Y').charAt(0).toUpperCase()}
</div>
{localParticipant?.isSpeaking && (
<div className="absolute inset-0 rounded-full ring-[3px] ring-discord-green" />
)}
</div>
)}
<span className="text-discord-text-secondary text-sm font-medium">
{authUser?.displayName ?? authUser?.username ?? 'You'} (You)
</span>
</div>
</div>
{/* Control bar */}
<div className="h-[72px] bg-[#1e1f22] flex items-center justify-center gap-4 px-4 flex-shrink-0">
{/* Mute */}
<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'}
>
{isMuted ? (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
) : (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
)}
</button>
{/* Deafen */}
<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'}
>
{isDeafened ? (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
) : (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
)}
</button>
{/* Camera */}
<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'}
>
{isCameraOn ? (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
) : (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
)}
</button>
{/* Screen Share */}
<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'}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
{/* Spacer */}
<div className="w-[1px] h-8 bg-[#3f4147] mx-2" />
{/* End Call */}
<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"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
</div>
</div>
);
}
@@ -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" }) }) })] })] })] })] }));
}
@@ -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<ReturnType<typeof setTimeout> | 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 (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60" />
{/* Call card */}
<div className="relative bg-[#1e1f22] rounded-lg shadow-2xl w-[340px] overflow-hidden">
{/* Ring animation background */}
<div className="absolute inset-0 overflow-hidden">
<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' }} />
<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' }} />
</div>
{/* Content */}
<div className="relative p-8 flex flex-col items-center gap-4">
{/* Caller avatar */}
<div className="relative">
<div className="w-20 h-20 rounded-full bg-discord-blurple flex items-center justify-center text-white text-3xl font-bold">
{incomingCall.callerName.charAt(0).toUpperCase()}
</div>
{/* Ringing phone icon */}
<div className="absolute -bottom-1 -right-1 w-7 h-7 rounded-full bg-discord-green flex items-center justify-center">
<svg width="16" height="16" viewBox="0 0 24 24" fill="white">
<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" />
</svg>
</div>
</div>
{/* Caller info */}
<div className="text-center">
<h3 className="text-[20px] font-bold text-discord-text-header">{incomingCall.callerName}</h3>
<p className="text-[14px] text-discord-text-muted mt-1">Incoming Voice Call...</p>
</div>
{/* Action buttons */}
<div className="flex items-center gap-6 mt-2">
{/* Decline */}
<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"
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="white" className="group-hover:scale-110 transition-transform">
<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" />
</svg>
</button>
{/* Accept */}
<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"
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="white" className="group-hover:scale-110 transition-transform">
<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" />
</svg>
</button>
</div>
</div>
</div>
</div>
);
}
@@ -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))) })] }));
}
@@ -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<string, VideoPreset> = {
'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<HTMLDivElement>(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 (
<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"
>
<div className="px-3 py-2 border-b border-[#1e1f22]">
<span className="text-[14px] font-bold text-discord-text-primary">Video Quality</span>
</div>
<div className="py-1">
{PRESETS.map((preset) => (
<button
key={preset.value}
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'
}`}
>
<div className="text-left">
<div className="text-[14px] font-medium">{preset.label}</div>
<div className="text-[12px] text-discord-text-muted">{preset.desc}</div>
</div>
{videoQuality === preset.value && (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-discord-blurple flex-shrink-0 ml-2">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
)}
</button>
))}
</div>
</div>
);
}
@@ -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 })] }));
}
@@ -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 (
<div className="w-[340px] flex-shrink-0 bg-discord-bg-primary flex flex-col border-l border-[#2b2d31]">
{/* Chat header */}
<div className="h-12 px-4 flex items-center justify-between shadow-header flex-shrink-0">
<span className="font-bold text-discord-text-primary text-[16px]">Chat</span>
<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"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
</div>
{/* Messages */}
<MessageList channelId={channelId} />
{/* Typing indicator */}
<TypingIndicator channelId={channelId} />
{/* Input */}
<MessageInput channelId={channelId} channelName={channelName} />
</div>
);
}
@@ -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" }) }) })] }));
}
@@ -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<string, any> = {
'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 (
<div className="h-[72px] bg-[#1a1b1e] flex items-center justify-center gap-2 px-4 flex-shrink-0">
{/* Mute */}
<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)'}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
<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) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
</svg>
</button>
{/* Deafen */}
<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)'}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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 && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
</svg>
</button>
{/* Camera */}
<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'}
>
{isCameraOn ? (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
) : (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
<line x1="2" y1="2" x2="22" y2="22" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
)}
</button>
{/* Screen Share */}
<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'}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
<path d="M15 11L11 14V12H9V10H11V8L15 11Z" />
</svg>
</button>
{/* Video Quality */}
<div className="relative">
<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"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
<VideoQualityPopover open={qualityOpen} onClose={() => setQualityOpen(false)} />
</div>
{/* Separator */}
<div className="w-[1px] h-8 bg-[#3f4147] mx-1" />
{/* Chat Toggle */}
<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"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
{/* Fullscreen Toggle */}
<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'}
>
{voiceFullscreen ? (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
</svg>
) : (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
</svg>
)}
</button>
{/* Separator */}
<div className="w-[1px] h-8 bg-[#3f4147] mx-1" />
{/* Disconnect */}
<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"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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" />
</svg>
</button>
</div>
);
}
@@ -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" })] }) })] })] }));
}
@@ -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 (
<div className="bg-[#232428] border-t border-discord-bg-tertiary">
{/* Row 1: Signal icon + status text + right icons */}
{/* Row 1: Signal icon + status text + disconnect */}
<div className="flex items-center gap-2 px-2 pt-[10px] pb-1">
{/* Signal icon */}
<div className={`w-8 h-8 rounded-lg ${statusBgColor} flex items-center justify-center flex-shrink-0`}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className={statusColor}>
<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" />
</svg>
</div>
{/* Status text */}
<div className="min-w-0 flex-1">
<div className={`text-[13px] font-semibold leading-[18px] ${statusColor}`}>
{connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'}
@@ -79,15 +122,12 @@ export function VoiceControls() {
</div>
</div>
{/* Right icons */}
<div className="flex items-center gap-0.5 flex-shrink-0">
{/* Signal quality */}
<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">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 20h2V8H2v12zm5 0h2V4H7v16zm5 0h2v-8h-2v8zm5 0h2V12h-2v8z" />
</svg>
</button>
{/* Disconnect */}
<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"
@@ -100,8 +140,41 @@ export function VoiceControls() {
</div>
</div>
{/* Row 2: Media control buttons */}
{/* Row 2: Mute, Deafen, Camera, Screen Share */}
<div className="flex items-center gap-1 px-2 pb-[10px] pt-1">
{/* Mute */}
<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'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<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" />
<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) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
</svg>
</button>
{/* Deafen */}
<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'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<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 && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
</svg>
</button>
{/* Camera */}
<button
onClick={handleCamera}
@@ -139,26 +212,6 @@ export function VoiceControls() {
<path d="M15 11L11 14V12H9V10H11V8L15 11Z" />
</svg>
</button>
{/* Noise Suppression */}
<button
className="flex-1 h-[34px] flex items-center justify-center rounded-[4px] bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary transition-colors"
title="Noise Suppression"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L9.19 8.63L2 9.24L7.46 13.97L5.82 21L12 17.27L18.18 21L16.54 13.97L22 9.24L14.81 8.63L12 2Z" />
</svg>
</button>
{/* Activities */}
<button
className="flex-1 h-[34px] flex items-center justify-center rounded-[4px] bg-discord-bg-tertiary text-discord-text-muted hover:bg-discord-bg-tertiary/80 hover:text-discord-text-secondary transition-colors"
title="Activities"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M7.5 2C5.01 2 3 4.01 3 6.5C3 8.99 5.01 11 7.5 11S12 8.99 12 6.5C12 4.01 9.99 2 7.5 2ZM16.5 2C14.01 2 12 4.01 12 6.5C12 8.99 14.01 11 16.5 11S21 8.99 21 6.5C21 4.01 18.99 2 16.5 2ZM7.5 13C5.01 13 3 15.01 3 17.5S5.01 22 7.5 22 12 19.99 12 17.5 9.99 13 7.5 13ZM16.5 13C14.01 13 12 15.01 12 17.5S14.01 22 16.5 22 21 19.99 21 17.5 18.99 13 16.5 13Z" />
</svg>
</button>
</div>
</div>
);
+17 -3
View File
@@ -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))) }) }));
}
@@ -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 (
<div className="flex-1 flex items-center justify-center text-discord-text-muted">
@@ -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 (
<div className="flex-1 flex overflow-hidden">
{/* Main focused view */}
<div
className="flex-1 p-2"
onDoubleClick={() => setFocusedParticipant(null)}
>
<VoiceUser participant={focusedParticipant} large />
</div>
{/* Side strip of other participants */}
{otherParticipants.length > 0 && (
<div className="w-[200px] flex-shrink-0 overflow-y-auto p-2 space-y-2">
{otherParticipants.map((p) => (
<div
key={p.identity}
onClick={() => setFocusedParticipant(p.identity)}
className="cursor-pointer"
>
<VoiceUser participant={p} />
</div>
))}
</div>
)}
</div>
);
}
// 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 (
<div className="flex-1 p-4 overflow-auto">
<div className={`grid ${gridClass} gap-2 h-full`}>
{participants.map((p) => (
<VoiceUser key={p.identity} participant={p} />
<div
key={p.identity}
onClick={() => setFocusedParticipant(p.identity)}
className="cursor-pointer"
>
<VoiceUser participant={p} />
</div>
))}
</div>
</div>
+38 -7
View File
@@ -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, "%"] })] })] }))] }));
}
+92 -20
View File
@@ -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<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(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 (
<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' }}
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}
>
{/* Audio element for remote participants */}
{!isLocal && <audio ref={audioRef} autoPlay />}
@@ -60,20 +90,31 @@ export function VoiceUser({ participant }: VoiceUserProps) {
autoPlay
playsInline
muted={isLocal}
className="w-full h-full object-cover"
className={`w-full h-full ${large ? 'object-contain' : 'object-cover'}`}
style={{
imageRendering: 'crisp-edges',
WebkitFontSmoothing: 'antialiased'
} as any}
/>
) : (
<Avatar
src={null}
name={participant.username}
size={80}
/>
<div className="flex flex-col items-center justify-center gap-2">
<Avatar
src={null}
name={participant.username}
size={large ? 100 : 80}
/>
</div>
)}
{/* Bottom overlay */}
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-white">{participant.username}</span>
<div className="flex items-center gap-1.5">
<span className={`font-medium text-white ${large ? 'text-base' : 'text-sm'}`}>{participant.username}</span>
{isLocal && (
<span className="text-[10px] text-white/50 font-medium">(you)</span>
)}
</div>
<div className="flex items-center gap-1">
{participant.isMuted && (
<div className="w-5 h-5 bg-discord-red/80 rounded-full flex items-center justify-center">
@@ -83,13 +124,44 @@ export function VoiceUser({ participant }: VoiceUserProps) {
</svg>
</div>
)}
{participant.isScreenSharing && (
<div className="w-5 h-5 bg-discord-blurple/80 rounded-full flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
<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" />
</svg>
</div>
)}
</div>
</div>
</div>
{/* Speaking indicator */}
{participant.isSpeaking && (
<div className="absolute inset-0 rounded-xl ring-2 ring-discord-green animate-pulse pointer-events-none" />
{/* Per-participant volume menu (right-click) */}
{volumeMenu && !isLocal && (
<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()}
>
<div className="text-xs text-discord-text-muted mb-2 font-medium uppercase tracking-wider">
User Volume
</div>
<div className="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted flex-shrink-0">
<path d="M3 9v6h4l5 5V4L7 9H3z" />
</svg>
<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"
/>
<span className="text-xs text-discord-text-secondary min-w-[32px] text-right">
{perUserVolume}%
</span>
</div>
</div>
)}
</div>
);
+203 -119
View File
@@ -1,22 +1,59 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { Room, RoomEvent, Track, ConnectionState, } from 'livekit-client';
import { Room, RoomEvent, Track, ConnectionState, VideoPresets, VideoPreset, } from 'livekit-client';
import { api } from '../api/client';
import { useVoiceStore } from '../stores/voiceStore';
// Module-level reference so other components (e.g. VoiceControls)
// can call LiveKit SDK methods directly without prop drilling.
/**
* OPENCORD NATIVE OVERDRIVE PIPELINE v16 (Golden Config)
* Restored exact v4 logic + Auto 720p60 default.
*/
const QUALITY_MAP = {
'1080p60': new VideoPreset(1920, 1080, 10_000_000, 60),
'1080p': new VideoPreset(1920, 1080, 5_000_000, 30),
'720p60': new VideoPreset(1280, 720, 5_000_000, 60), // v4 Golden Value
'720p': new VideoPreset(1280, 720, 3_000_000, 30),
'540p': new VideoPreset(960, 540, 1_500_000, 30),
'360p': new VideoPreset(640, 360, 800_000, 30),
};
// AUTO defaults to the stable 720p60 preset
const AUTO_PRESET = QUALITY_MAP['720p60'];
let _activeRoom = null;
export function getActiveRoom() {
return _activeRoom;
}
function parseIdentity(identity) {
const parts = identity.split(':');
return {
userId: parts[0] ?? identity,
username: parts[1] ?? identity,
};
return { userId: parts[0] ?? identity, username: parts[1] ?? identity };
}
// Connection lock to prevent concurrent connect() calls from racing
let _connectGeneration = 0;
// The v4 "Triple-Kick" Hammer
async function applyOverdriveHammer(room, source, preset) {
try {
const pub = room.localParticipant.getTrackPublications().find(p => p.source === source);
if (!pub?.track)
return;
const engine = room.engine;
const pc = engine?.pcManager?.publisher?.pc || engine?.publisher?.pc || engine?.pc;
if (pc) {
const senders = pc.getSenders();
const sender = senders.find(s => s.track?.id === pub.track?.mediaStreamTrack?.id);
if (sender) {
const params = sender.getParameters();
if (params.encodings && params.encodings[0]) {
console.log(`[Overdrive] Kicking ${source} to ${preset.encoding.maxBitrate}bps`);
params.encodings[0].maxBitrate = preset.encoding.maxBitrate;
params.encodings[0].maxFramerate = preset.encoding.maxFramerate;
// @ts-ignore
params.degradationPreference = 'maintain-framerate';
await sender.setParameters(params);
}
}
}
if (pub.track.mediaStreamTrack) {
pub.track.mediaStreamTrack.contentHint = 'motion';
}
}
catch (err) { }
}
export function useLiveKit() {
const [room, setRoom] = useState(null);
const [participants, setParticipants] = useState([]);
@@ -28,6 +65,7 @@ export function useLiveKit() {
const isMuted = useVoiceStore((s) => s.isMuted);
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const videoQuality = useVoiceStore((s) => s.videoQuality);
const updateParticipants = useCallback(() => {
const r = roomRef.current;
if (!r)
@@ -42,155 +80,142 @@ export function useLiveKit() {
const track = pub.track;
if (!track)
return;
if (pub.source === Track.Source.Microphone) {
if (pub.source === Track.Source.Microphone)
audioTrack = track.mediaStreamTrack;
}
else if (pub.source === Track.Source.Camera) {
else if (pub.source === Track.Source.Camera)
videoTrack = track.mediaStreamTrack;
}
else if (pub.source === Track.Source.ScreenShare) {
else if (pub.source === Track.Source.ScreenShare)
screenTrack = track.mediaStreamTrack;
}
});
allParticipants.push({
identity: p.identity,
userId,
username,
isSpeaking: p.isSpeaking,
isMuted: !p.isMicrophoneEnabled,
isCameraOn: p.isCameraEnabled,
isScreenSharing: p.isScreenShareEnabled,
isLocal,
audioTrack,
videoTrack,
screenTrack,
});
allParticipants.push({ identity: p.identity, userId, username, isSpeaking: p.isSpeaking, isMuted: !p.isMicrophoneEnabled, isCameraOn: p.isCameraEnabled, isScreenSharing: p.isScreenShareEnabled, isLocal, audioTrack, videoTrack, screenTrack });
};
processParticipant(r.localParticipant, true);
r.remoteParticipants.forEach((p) => processParticipant(p, false));
setParticipants(allParticipants);
}, []);
const connect = useCallback(async (channelId) => {
// Don't reconnect if already connected to this channel
if (connectedChannelRef.current === channelId && roomRef.current) {
console.log('[LiveKit] Already connected to channel:', channelId);
if (connectedChannelRef.current === channelId && roomRef.current)
return;
}
// Bump generation — any in-flight connect with an older generation
// will bail out after its async gaps.
const gen = ++_connectGeneration;
console.log('[LiveKit] connect() gen=%d channel=%s', gen, channelId);
// Tear down any existing room synchronously
if (roomRef.current) {
try {
roomRef.current.disconnect();
}
catch { }
roomRef.current = null;
_activeRoom = null;
connectedChannelRef.current = null;
}
setIsConnecting(true);
setConnectionError(null);
useVoiceStore.getState().setConnectionError(null);
useVoiceStore.getState().setIsLiveKitConnected(false);
try {
console.log('[LiveKit] Fetching token for channel:', channelId);
const { token, url } = await api.livekit.token(channelId);
// Abort if a newer connect() was called while we were fetching the token
if (gen !== _connectGeneration) {
console.log('[LiveKit] gen=%d aborted (superseded by gen=%d)', gen, _connectGeneration);
if (gen !== _connectGeneration)
return;
}
console.log('[LiveKit] Got token, connecting to:', url);
const newRoom = new Room({
adaptiveStream: true,
dynacast: true,
});
// Guard all event handlers: only update state if this room is still current.
// Without this, stale events from old rooms corrupt the new room's state.
const guardedUpdate = () => {
if (roomRef.current === newRoom) updateParticipants();
};
newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } });
roomRef.current = newRoom;
const guardedUpdate = () => { if (roomRef.current === newRoom)
updateParticipants(); };
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
console.log('[LiveKit] ConnectionStateChanged:', state, 'isCurrentRoom:', roomRef.current === newRoom);
// Only update state if this room is still the active one
if (roomRef.current === newRoom) {
setIsConnected(state === ConnectionState.Connected);
const connected = state === ConnectionState.Connected;
setIsConnected(connected);
useVoiceStore.getState().setIsLiveKitConnected(connected);
}
});
newRoom.on(RoomEvent.Disconnected, () => {
console.log('[LiveKit] Disconnected event fired, isCurrentRoom:', roomRef.current === newRoom);
// CRITICAL: Only clear state if this room is still the active one.
// If a newer connect() has already replaced us, don't nuke its state.
if (roomRef.current !== newRoom) {
console.log('[LiveKit] Ignoring stale Disconnected event from old room');
if (roomRef.current !== newRoom)
return;
}
roomRef.current = null;
_activeRoom = null;
connectedChannelRef.current = null;
setIsConnected(false);
setRoom(null);
setParticipants([]);
useVoiceStore.getState().setIsLiveKitConnected(false);
useVoiceStore.getState().setConnectionError('Disconnected from voice');
});
await newRoom.connect(url, token);
// Abort if a newer connect() was called while we were connecting
if (gen !== _connectGeneration) {
console.log('[LiveKit] gen=%d aborted after connect (superseded)', gen);
newRoom.disconnect();
return;
}
console.log('[LiveKit] Connected successfully! gen=%d', gen);
roomRef.current = newRoom;
_activeRoom = newRoom;
connectedChannelRef.current = channelId;
setRoom(newRoom);
setIsConnected(true);
useVoiceStore.getState().setIsLiveKitConnected(true);
useVoiceStore.getState().setConnectionError(null);
updateParticipants();
// Enable mic only (not camera) by default.
// Reset media state in store to match SDK state — prevents desync after reconnects.
useVoiceStore.setState({ isMuted: false, isCameraOn: false, isScreenSharing: false });
try {
await newRoom.localParticipant.setMicrophoneEnabled(true);
console.log('[LiveKit] Microphone enabled');
updateParticipants();
}
catch (mediaErr) {
console.warn('[LiveKit] Could not enable microphone:', mediaErr);
// Mic failed to enable — mark as muted in store
catch {
useVoiceStore.setState({ isMuted: true });
}
}
catch (err) {
// Only set error if this is still the active generation
if (gen === _connectGeneration) {
const message = err instanceof Error ? err.message : 'Failed to connect to voice';
console.error('[LiveKit] Connection failed:', err);
connectedChannelRef.current = null;
setConnectionError(message);
useVoiceStore.getState().setConnectionError(message);
setConnectionError('Failed to connect');
useVoiceStore.getState().setConnectionError('Failed to connect');
}
}
finally {
if (gen === _connectGeneration) {
if (gen === _connectGeneration)
setIsConnecting(false);
}
}, [updateParticipants]);
const connectDm = useCallback(async (dmChannelId) => {
const gen = ++_connectGeneration;
if (roomRef.current) {
try {
roomRef.current.disconnect();
}
catch { }
roomRef.current = null;
}
setIsConnecting(true);
try {
const { token, url } = await api.livekit.dmToken(dmChannelId);
if (gen !== _connectGeneration)
return;
const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } });
roomRef.current = newRoom;
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) {
const connected = state === ConnectionState.Connected;
setIsConnected(connected);
useVoiceStore.getState().setIsLiveKitConnected(connected);
}
});
await newRoom.connect(url, token);
if (gen !== _connectGeneration) {
newRoom.disconnect();
return;
}
_activeRoom = newRoom;
connectedChannelRef.current = `dm-${dmChannelId}`;
setRoom(newRoom);
setIsConnected(true);
useVoiceStore.getState().setIsLiveKitConnected(true);
updateParticipants();
useVoiceStore.setState({ isMuted: false, isCameraOn: false, isScreenSharing: false });
try {
await newRoom.localParticipant.setMicrophoneEnabled(true);
updateParticipants();
}
catch {
useVoiceStore.setState({ isMuted: true });
}
}
catch (err) {
if (gen === _connectGeneration)
setConnectionError('Failed to connect');
}
finally {
if (gen === _connectGeneration)
setIsConnecting(false);
}
}, [updateParticipants]);
const disconnect = useCallback(async () => {
// Bump generation so any in-flight connect aborts
_connectGeneration++;
if (roomRef.current) {
await roomRef.current.disconnect();
@@ -203,45 +228,104 @@ export function useLiveKit() {
useVoiceStore.getState().setIsLiveKitConnected(false);
}
}, []);
const toggleMic = useCallback(async () => {
if (roomRef.current) {
await roomRef.current.localParticipant.setMicrophoneEnabled(isMuted);
updateParticipants();
}
}, [isMuted, updateParticipants]);
const toggleMic = useCallback(async () => { if (roomRef.current) {
await roomRef.current.localParticipant.setMicrophoneEnabled(isMuted);
updateParticipants();
} }, [isMuted, updateParticipants]);
const toggleCamera = useCallback(async () => {
if (roomRef.current) {
await roomRef.current.localParticipant.setCameraEnabled(!isCameraOn);
if (!isCameraOn) {
const preset = QUALITY_MAP[videoQuality] || VideoPresets.h720;
await roomRef.current.localParticipant.setCameraEnabled(true, { resolution: preset.resolution, frameRate: preset.encoding.maxFramerate }, { videoCodec: 'h264', videoEncoding: preset.encoding, simulcast: false });
setTimeout(() => { if (roomRef.current)
applyOverdriveHammer(roomRef.current, Track.Source.Camera, preset); }, 1000);
}
else {
await roomRef.current.localParticipant.setCameraEnabled(false);
}
updateParticipants();
}
}, [isCameraOn, updateParticipants]);
}, [isCameraOn, videoQuality, updateParticipants]);
const toggleScreenShare = useCallback(async () => {
if (roomRef.current) {
await roomRef.current.localParticipant.setScreenShareEnabled(!isScreenSharing);
if (!isScreenSharing) {
const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
console.log('[LiveKit] Starting v4 Golden screen share...');
const track = await roomRef.current.localParticipant.setScreenShareEnabled(true, {
resolution: preset.resolution,
// @ts-ignore
frameRate: 60,
}, {
videoCodec: 'h264', videoEncoding: preset.encoding, simulcast: false, priority: 'very-high'
});
if (track) {
const hammer = () => { if (roomRef.current)
applyOverdriveHammer(roomRef.current, Track.Source.ScreenShare, preset); };
// Restore exact v4 timing: 0.5s, 2s, 5s
setTimeout(hammer, 500);
setTimeout(hammer, 2000);
setTimeout(hammer, 5000);
}
}
else {
await roomRef.current.localParticipant.setScreenShareEnabled(false);
}
updateParticipants();
}
}, [isScreenSharing, updateParticipants]);
}, [isScreenSharing, videoQuality, updateParticipants]);
// Sync quality changes
useEffect(() => {
return () => {
_connectGeneration++;
if (roomRef.current) {
roomRef.current.disconnect();
roomRef.current = null;
_activeRoom = null;
connectedChannelRef.current = null;
if (!room)
return;
const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
const updateActiveTracks = async () => {
if (isScreenSharing) {
const screenPub = room.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
if (screenPub?.videoTrack) {
const mediaTrack = screenPub.videoTrack.mediaStreamTrack;
if (mediaTrack) {
await mediaTrack.applyConstraints({ width: { ideal: preset.resolution.width }, height: { ideal: preset.resolution.height }, frameRate: { ideal: preset.encoding.maxFramerate, min: 30 } });
}
await applyOverdriveHammer(room, Track.Source.ScreenShare, preset);
}
}
if (isCameraOn) {
await applyOverdriveHammer(room, Track.Source.Camera, preset);
}
};
updateActiveTracks().catch(() => { });
}, [room, videoQuality, isScreenSharing, isCameraOn]);
useEffect(() => {
if (!room)
return;
const interval = setInterval(async () => {
try {
const engine = room.engine;
const pc = engine?.pcManager?.publisher?.pc || engine?.publisher?.pc || engine?.pc || room.pc;
if (!pc)
return;
const stats = await pc.getStats();
stats.forEach((report) => {
if (report.type === 'outbound-rtp' && report.kind === 'video' && report.frameWidth > 0) {
const fps = Math.round(report.framesPerSecond || 0);
const key = `_lastBytes_${report.ssrc}`;
const lastBytes = window[key] || report.bytesSent;
const bitrate = (((report.bytesSent - lastBytes) * 8) / 5000 / 1000).toFixed(2);
window[key] = report.bytesSent;
console.log(`[Overdrive Diagnostic] ${report.frameWidth}x${report.frameHeight} @ ${fps} FPS (~${bitrate} Mbps) | ${report.qualityLimitationReason}`);
}
});
}
catch (err) { }
}, 5000);
return () => clearInterval(interval);
}, [room]);
useEffect(() => {
return () => { _connectGeneration++; if (roomRef.current) {
roomRef.current.disconnect();
roomRef.current = null;
_activeRoom = null;
} };
}, []);
return {
room,
participants,
isConnected,
isConnecting,
connectionError,
connect,
disconnect,
toggleMic,
toggleCamera,
toggleScreenShare,
};
return { room, participants, isConnected, isConnecting, connectionError, connect, connectDm, disconnect, toggleMic, toggleCamera, toggleScreenShare };
}
+174 -174
View File
@@ -9,12 +9,30 @@ import {
RemoteParticipant,
LocalParticipant,
ConnectionState,
VideoPresets,
VideoEncoding,
VideoPreset,
} from 'livekit-client';
import { api } from '../api/client';
import { useVoiceStore } from '../stores/voiceStore';
// Module-level reference so other components (e.g. VoiceControls)
// can call LiveKit SDK methods directly without prop drilling.
/**
* OPENCORD NATIVE OVERDRIVE PIPELINE v16 (Golden Config)
* Restored exact v4 logic + Auto 720p60 default.
*/
const QUALITY_MAP: Record<string, VideoPreset> = {
'1080p60': new VideoPreset(1920, 1080, 10_000_000, 60),
'1080p': new VideoPreset(1920, 1080, 5_000_000, 30),
'720p60': new VideoPreset(1280, 720, 5_000_000, 60), // v4 Golden Value
'720p': new VideoPreset(1280, 720, 3_000_000, 30),
'540p': new VideoPreset(960, 540, 1_500_000, 30),
'360p': new VideoPreset(640, 360, 800_000, 30),
};
// AUTO defaults to the stable 720p60 preset
const AUTO_PRESET = QUALITY_MAP['720p60']!;
let _activeRoom: Room | null = null;
export function getActiveRoom(): Room | null {
@@ -37,15 +55,41 @@ export interface ParticipantInfo {
function parseIdentity(identity: string): { userId: string; username: string } {
const parts = identity.split(':');
return {
userId: parts[0] ?? identity,
username: parts[1] ?? identity,
};
return { userId: parts[0] ?? identity, username: parts[1] ?? identity };
}
// Connection lock to prevent concurrent connect() calls from racing
let _connectGeneration = 0;
// The v4 "Triple-Kick" Hammer
async function applyOverdriveHammer(room: Room, source: Track.Source, preset: VideoPreset) {
try {
const pub = room.localParticipant.getTrackPublications().find(p => p.source === source);
if (!pub?.track) return;
const engine = (room as any).engine;
const pc = engine?.pcManager?.publisher?.pc || engine?.publisher?.pc || engine?.pc;
if (pc) {
const senders = (pc as RTCPeerConnection).getSenders();
const sender = senders.find(s => s.track?.id === pub.track?.mediaStreamTrack?.id);
if (sender) {
const params = sender.getParameters();
if (params.encodings && params.encodings[0]) {
console.log(`[Overdrive] Kicking ${source} to ${preset.encoding.maxBitrate}bps`);
params.encodings[0].maxBitrate = preset.encoding.maxBitrate;
params.encodings[0].maxFramerate = preset.encoding.maxFramerate;
// @ts-ignore
params.degradationPreference = 'maintain-framerate';
await sender.setParameters(params);
}
}
}
if ((pub.track as any).mediaStreamTrack) {
(pub.track as any).mediaStreamTrack.contentHint = 'motion';
}
} catch (err) {}
}
export function useLiveKit() {
const [room, setRoom] = useState<Room | null>(null);
const [participants, setParticipants] = useState<ParticipantInfo[]>([]);
@@ -57,235 +101,191 @@ export function useLiveKit() {
const isMuted = useVoiceStore((s) => s.isMuted);
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const videoQuality = useVoiceStore((s) => s.videoQuality);
const updateParticipants = useCallback(() => {
const r = roomRef.current;
if (!r) return;
const allParticipants: ParticipantInfo[] = [];
const processParticipant = (p: Participant, isLocal: boolean) => {
const { userId, username } = parseIdentity(p.identity);
let audioTrack: MediaStreamTrack | null = null;
let videoTrack: MediaStreamTrack | null = null;
let screenTrack: MediaStreamTrack | null = null;
p.trackPublications.forEach((pub) => {
const track = pub.track;
if (!track) return;
if (pub.source === Track.Source.Microphone) {
audioTrack = track.mediaStreamTrack;
} else if (pub.source === Track.Source.Camera) {
videoTrack = track.mediaStreamTrack;
} else if (pub.source === Track.Source.ScreenShare) {
screenTrack = track.mediaStreamTrack;
}
});
allParticipants.push({
identity: p.identity,
userId,
username,
isSpeaking: p.isSpeaking,
isMuted: !p.isMicrophoneEnabled,
isCameraOn: p.isCameraEnabled,
isScreenSharing: p.isScreenShareEnabled,
isLocal,
audioTrack,
videoTrack,
screenTrack,
if (pub.source === Track.Source.Microphone) audioTrack = track.mediaStreamTrack;
else if (pub.source === Track.Source.Camera) videoTrack = track.mediaStreamTrack;
else if (pub.source === Track.Source.ScreenShare) screenTrack = track.mediaStreamTrack;
});
allParticipants.push({ identity: p.identity, userId, username, isSpeaking: p.isSpeaking, isMuted: !p.isMicrophoneEnabled, isCameraOn: p.isCameraEnabled, isScreenSharing: p.isScreenShareEnabled, isLocal, audioTrack, videoTrack, screenTrack });
};
processParticipant(r.localParticipant, true);
r.remoteParticipants.forEach((p) => processParticipant(p, false));
setParticipants(allParticipants);
}, []);
const connect = useCallback(async (channelId: string) => {
// Don't reconnect if already connected to this channel
if (connectedChannelRef.current === channelId && roomRef.current) {
console.log('[LiveKit] Already connected to channel:', channelId);
return;
}
// Bump generation — any in-flight connect with an older generation
// will bail out after its async gaps.
if (connectedChannelRef.current === channelId && roomRef.current) return;
const gen = ++_connectGeneration;
console.log('[LiveKit] connect() gen=%d channel=%s', gen, channelId);
// Tear down any existing room synchronously
if (roomRef.current) {
try { roomRef.current.disconnect(); } catch {}
roomRef.current = null;
_activeRoom = null;
connectedChannelRef.current = null;
}
if (roomRef.current) { try { roomRef.current.disconnect(); } catch {} roomRef.current = null; }
setIsConnecting(true);
setConnectionError(null);
useVoiceStore.getState().setConnectionError(null);
useVoiceStore.getState().setIsLiveKitConnected(false);
try {
console.log('[LiveKit] Fetching token for channel:', channelId);
const { token, url } = await api.livekit.token(channelId);
if (gen !== _connectGeneration) return;
// Abort if a newer connect() was called while we were fetching the token
if (gen !== _connectGeneration) {
console.log('[LiveKit] gen=%d aborted (superseded by gen=%d)', gen, _connectGeneration);
return;
}
console.log('[LiveKit] Got token, connecting to:', url);
const newRoom = new Room({
adaptiveStream: true,
dynacast: true,
});
// Guard all event handlers: only update state if this room is still current.
// Without this, stale events from old rooms corrupt the new room's state.
const guardedUpdate = () => {
if (roomRef.current === newRoom) updateParticipants();
};
newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } });
roomRef.current = newRoom;
const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); };
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
console.log('[LiveKit] ConnectionStateChanged:', state, 'isCurrentRoom:', roomRef.current === newRoom);
// Only update state if this room is still the active one
if (roomRef.current === newRoom) {
setIsConnected(state === ConnectionState.Connected);
const connected = state === ConnectionState.Connected;
setIsConnected(connected);
useVoiceStore.getState().setIsLiveKitConnected(connected);
}
});
newRoom.on(RoomEvent.Disconnected, () => {
console.log('[LiveKit] Disconnected event fired, isCurrentRoom:', roomRef.current === newRoom);
// CRITICAL: Only clear state if this room is still the active one.
// If a newer connect() has already replaced us, don't nuke its state.
if (roomRef.current !== newRoom) {
console.log('[LiveKit] Ignoring stale Disconnected event from old room');
return;
}
roomRef.current = null;
_activeRoom = null;
connectedChannelRef.current = null;
setIsConnected(false);
setRoom(null);
setParticipants([]);
if (roomRef.current !== newRoom) return;
roomRef.current = null; _activeRoom = null; setIsConnected(false); setRoom(null); setParticipants([]);
useVoiceStore.getState().setIsLiveKitConnected(false);
useVoiceStore.getState().setConnectionError('Disconnected from voice');
});
await newRoom.connect(url, token);
if (gen !== _connectGeneration) { newRoom.disconnect(); return; }
// Abort if a newer connect() was called while we were connecting
if (gen !== _connectGeneration) {
console.log('[LiveKit] gen=%d aborted after connect (superseded)', gen);
newRoom.disconnect();
return;
}
console.log('[LiveKit] Connected successfully! gen=%d', gen);
roomRef.current = newRoom;
_activeRoom = newRoom;
connectedChannelRef.current = channelId;
setRoom(newRoom);
setIsConnected(true);
_activeRoom = newRoom; connectedChannelRef.current = channelId;
setRoom(newRoom); setIsConnected(true);
useVoiceStore.getState().setIsLiveKitConnected(true);
useVoiceStore.getState().setConnectionError(null);
updateParticipants();
// Enable mic only (not camera) by default.
// Reset media state in store to match SDK state — prevents desync after reconnects.
useVoiceStore.setState({ isMuted: false, isCameraOn: false, isScreenSharing: false });
try {
await newRoom.localParticipant.setMicrophoneEnabled(true);
console.log('[LiveKit] Microphone enabled');
updateParticipants();
} catch (mediaErr) {
console.warn('[LiveKit] Could not enable microphone:', mediaErr);
// Mic failed to enable — mark as muted in store
useVoiceStore.setState({ isMuted: true });
}
try { await newRoom.localParticipant.setMicrophoneEnabled(true); updateParticipants(); } catch { useVoiceStore.setState({ isMuted: true }); }
} catch (err) {
// Only set error if this is still the active generation
if (gen === _connectGeneration) {
const message = err instanceof Error ? err.message : 'Failed to connect to voice';
console.error('[LiveKit] Connection failed:', err);
connectedChannelRef.current = null;
setConnectionError(message);
useVoiceStore.getState().setConnectionError(message);
}
} finally {
if (gen === _connectGeneration) {
setIsConnecting(false);
}
}
if (gen === _connectGeneration) { setConnectionError('Failed to connect'); useVoiceStore.getState().setConnectionError('Failed to connect'); }
} finally { if (gen === _connectGeneration) setIsConnecting(false); }
}, [updateParticipants]);
const connectDm = useCallback(async (dmChannelId: string) => {
const gen = ++_connectGeneration;
if (roomRef.current) { try { roomRef.current.disconnect(); } catch {} roomRef.current = null; }
setIsConnecting(true);
try {
const { token, url } = await api.livekit.dmToken(dmChannelId);
if (gen !== _connectGeneration) return;
const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } });
roomRef.current = newRoom;
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) {
const connected = state === ConnectionState.Connected;
setIsConnected(connected);
useVoiceStore.getState().setIsLiveKitConnected(connected);
}
});
await newRoom.connect(url, token);
if (gen !== _connectGeneration) { newRoom.disconnect(); return; }
_activeRoom = newRoom; connectedChannelRef.current = `dm-${dmChannelId}`; setRoom(newRoom); setIsConnected(true);
useVoiceStore.getState().setIsLiveKitConnected(true);
updateParticipants();
useVoiceStore.setState({ isMuted: false, isCameraOn: false, isScreenSharing: false });
try { await newRoom.localParticipant.setMicrophoneEnabled(true); updateParticipants(); } catch { useVoiceStore.setState({ isMuted: true }); }
} catch (err) { if (gen === _connectGeneration) setConnectionError('Failed to connect'); }
finally { if (gen === _connectGeneration) setIsConnecting(false); }
}, [updateParticipants]);
const disconnect = useCallback(async () => {
// Bump generation so any in-flight connect aborts
_connectGeneration++;
if (roomRef.current) {
await roomRef.current.disconnect();
roomRef.current = null;
_activeRoom = null;
connectedChannelRef.current = null;
setRoom(null);
setIsConnected(false);
setParticipants([]);
useVoiceStore.getState().setIsLiveKitConnected(false);
}
if (roomRef.current) { await roomRef.current.disconnect(); roomRef.current = null; _activeRoom = null; connectedChannelRef.current = null; setRoom(null); setIsConnected(false); setParticipants([]); useVoiceStore.getState().setIsLiveKitConnected(false); }
}, []);
const toggleMic = useCallback(async () => {
if (roomRef.current) {
await roomRef.current.localParticipant.setMicrophoneEnabled(isMuted);
updateParticipants();
}
}, [isMuted, updateParticipants]);
const toggleMic = useCallback(async () => { if (roomRef.current) { await roomRef.current.localParticipant.setMicrophoneEnabled(isMuted); updateParticipants(); } }, [isMuted, updateParticipants]);
const toggleCamera = useCallback(async () => {
if (roomRef.current) {
await roomRef.current.localParticipant.setCameraEnabled(!isCameraOn);
if (!isCameraOn) {
const preset = QUALITY_MAP[videoQuality] || VideoPresets.h720;
await roomRef.current.localParticipant.setCameraEnabled(true, { resolution: preset.resolution, frameRate: preset.encoding.maxFramerate }, { videoCodec: 'h264', videoEncoding: preset.encoding, simulcast: false });
setTimeout(() => { if (roomRef.current) applyOverdriveHammer(roomRef.current, Track.Source.Camera, preset); }, 1000);
} else { await roomRef.current.localParticipant.setCameraEnabled(false); }
updateParticipants();
}
}, [isCameraOn, updateParticipants]);
}, [isCameraOn, videoQuality, updateParticipants]);
const toggleScreenShare = useCallback(async () => {
if (roomRef.current) {
await roomRef.current.localParticipant.setScreenShareEnabled(!isScreenSharing);
if (!isScreenSharing) {
const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
console.log('[LiveKit] Starting v4 Golden screen share...');
const track = await roomRef.current.localParticipant.setScreenShareEnabled(true, {
resolution: preset.resolution,
// @ts-ignore
frameRate: 60,
}, {
videoCodec: 'h264', videoEncoding: preset.encoding, simulcast: false, priority: 'very-high'
} as any);
if (track) {
const hammer = () => { if (roomRef.current) applyOverdriveHammer(roomRef.current, Track.Source.ScreenShare, preset); };
// Restore exact v4 timing: 0.5s, 2s, 5s
setTimeout(hammer, 500);
setTimeout(hammer, 2000);
setTimeout(hammer, 5000);
}
} else { await roomRef.current.localParticipant.setScreenShareEnabled(false); }
updateParticipants();
}
}, [isScreenSharing, updateParticipants]);
}, [isScreenSharing, videoQuality, updateParticipants]);
// Sync quality changes
useEffect(() => {
if (!room) return;
const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
const updateActiveTracks = async () => {
if (isScreenSharing) {
const screenPub = room.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
if (screenPub?.videoTrack) {
const mediaTrack = (screenPub.videoTrack as any).mediaStreamTrack as MediaStreamTrack;
if (mediaTrack) {
await mediaTrack.applyConstraints({ width: { ideal: preset.resolution.width }, height: { ideal: preset.resolution.height }, frameRate: { ideal: preset.encoding.maxFramerate, min: 30 } });
}
await applyOverdriveHammer(room, Track.Source.ScreenShare, preset);
}
}
if (isCameraOn) { await applyOverdriveHammer(room, Track.Source.Camera, preset); }
};
updateActiveTracks().catch(() => {});
}, [room, videoQuality, isScreenSharing, isCameraOn]);
useEffect(() => {
return () => {
_connectGeneration++;
if (roomRef.current) {
roomRef.current.disconnect();
roomRef.current = null;
_activeRoom = null;
connectedChannelRef.current = null;
}
};
if (!room) return;
const interval = setInterval(async () => {
try {
const engine = (room as any).engine;
const pc = engine?.pcManager?.publisher?.pc || engine?.publisher?.pc || engine?.pc || (room as any).pc;
if (!pc) return;
const stats = await (pc as RTCPeerConnection).getStats();
stats.forEach((report: any) => {
if (report.type === 'outbound-rtp' && report.kind === 'video' && report.frameWidth > 0) {
const fps = Math.round(report.framesPerSecond || 0);
const key = `_lastBytes_${report.ssrc}`;
const lastBytes = (window as any)[key] || report.bytesSent;
const bitrate = (((report.bytesSent - lastBytes) * 8) / 5000 / 1000).toFixed(2);
(window as any)[key] = report.bytesSent;
console.log(`[Overdrive Diagnostic] ${report.frameWidth}x${report.frameHeight} @ ${fps} FPS (~${bitrate} Mbps) | ${report.qualityLimitationReason}`);
}
});
} catch (err) {}
}, 5000);
return () => clearInterval(interval);
}, [room]);
useEffect(() => {
return () => { _connectGeneration++; if (roomRef.current) { roomRef.current.disconnect(); roomRef.current = null; _activeRoom = null; } };
}, []);
return {
room,
participants,
isConnected,
isConnecting,
connectionError,
connect,
disconnect,
toggleMic,
toggleCamera,
toggleScreenShare,
};
return { room, participants, isConnected, isConnecting, connectionError, connect, connectDm, disconnect, toggleMic, toggleCamera, toggleScreenShare };
}
+36 -5
View File
@@ -28,6 +28,7 @@ function handleEvent(event) {
if (currentChannelId) {
reloadMessages(currentChannelId, true);
}
// Initialize unread tracking from ready payload
const { channelLastMessageIds } = useServerStore.getState();
if (event.readStates) {
setReadStates(event.readStates, channelLastMessageIds);
@@ -80,17 +81,17 @@ function handleEvent(event) {
addMessage(event.message.dmChannelId, event.message);
// Update lastMessage on the DM channel so the sidebar sorts correctly
const { dmChannels, setDmChannels } = useServerStore.getState();
const updatedDms = dmChannels.map(dm =>
dm.id === event.message.dmChannelId
? { ...dm, lastMessage: event.message }
: dm
);
const updatedDms = dmChannels.map(dm => dm.id === event.message.dmChannelId
? { ...dm, lastMessage: event.message }
: dm);
// Re-sort by most recent message
updatedDms.sort((a, b) => {
const aTime = a.lastMessage?.createdAt ?? a.createdAt;
const bTime = b.lastMessage?.createdAt ?? b.createdAt;
return bTime - aTime;
});
setDmChannels(updatedDms);
// Mark DM as unread if not currently viewing it
{
const { currentChannelId, markChannelUnread } = useChatStore.getState();
if (event.message.dmChannelId !== currentChannelId) {
@@ -129,6 +130,36 @@ function handleEvent(event) {
onChannelAck(event.channelId, event.messageId);
break;
}
case 'dm_call_incoming': {
const { setIncomingCall } = useVoiceStore.getState();
setIncomingCall({
dmChannelId: event.dmChannelId,
callerId: event.callerId,
callerName: event.callerName,
});
break;
}
case 'dm_call_accepted': {
const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState();
setIncomingCall(null);
setOutgoingCall(null);
setActiveDmCall({ dmChannelId: event.dmChannelId });
break;
}
case 'dm_call_rejected': {
const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState();
setIncomingCall(null);
setOutgoingCall(null);
setActiveDmCall(null);
break;
}
case 'dm_call_ended': {
const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState();
setIncomingCall(null);
setOutgoingCall(null);
setActiveDmCall(null);
break;
}
case 'error':
console.error('WebSocket error:', event.message);
break;
+34
View File
@@ -153,6 +153,40 @@ function handleEvent(event: ServerEvent): void {
break;
}
case 'dm_call_incoming': {
const { setIncomingCall } = useVoiceStore.getState();
setIncomingCall({
dmChannelId: event.dmChannelId,
callerId: event.callerId,
callerName: event.callerName,
});
break;
}
case 'dm_call_accepted': {
const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState();
setIncomingCall(null);
setOutgoingCall(null);
setActiveDmCall({ dmChannelId: event.dmChannelId });
break;
}
case 'dm_call_rejected': {
const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState();
setIncomingCall(null);
setOutgoingCall(null);
setActiveDmCall(null);
break;
}
case 'dm_call_ended': {
const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState();
setIncomingCall(null);
setOutgoingCall(null);
setActiveDmCall(null);
break;
}
case 'error':
console.error('WebSocket error:', event.message);
break;
+2 -2
View File
@@ -20,11 +20,11 @@ class ErrorBoundary extends React.Component {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#313338',
color: '#f2f3f5',
color: '#ffffff',
fontFamily: 'sans-serif',
flexDirection: 'column',
gap: '16px',
}, children: [_jsx("h1", { style: { fontSize: '24px', fontWeight: 'bold' }, children: "Something went wrong" }), _jsx("p", { style: { color: '#949ba4' }, children: this.state.error?.message }), _jsx("button", { onClick: () => window.location.reload(), style: {
}, children: [_jsx("h1", { style: { fontSize: '24px', fontWeight: 'bold' }, children: "Something went wrong" }), _jsx("p", { style: { color: '#abacb2' }, children: this.state.error?.message }), _jsx("button", { onClick: () => window.location.reload(), style: {
padding: '8px 24px',
backgroundColor: '#5865f2',
color: 'white',
+15 -6
View File
@@ -80,7 +80,8 @@ export const useChatStore = create((set, get) => ({
const isDm = isDmChannel(channelId);
if (isDm) {
await api.dm.updateMessage(messageId, { content });
} else {
}
else {
await api.messages.update(messageId, { content });
}
// Update will arrive via WebSocket
@@ -89,7 +90,8 @@ export const useChatStore = create((set, get) => ({
const isDm = isDmChannel(channelId);
if (isDm) {
await api.dm.deleteMessage(messageId);
} else {
}
else {
await api.messages.delete(messageId);
}
// Deletion will arrive via WebSocket
@@ -106,8 +108,10 @@ export const useChatStore = create((set, get) => ({
});
},
updateMessage: (message) => {
// DM messages have dmChannelId instead of channelId — check both
const channelKey = message.channelId || message.dmChannelId;
if (!channelKey) return;
if (!channelKey)
return;
set((state) => {
const newMessages = new Map(state.messages);
const current = newMessages.get(channelKey);
@@ -219,7 +223,8 @@ export const useChatStore = create((set, get) => ({
},
markChannelUnread: (channelId) => {
set((state) => {
if (state.unreadChannels.has(channelId)) return state;
if (state.unreadChannels.has(channelId))
return state;
const newUnread = new Set(state.unreadChannels);
newUnread.add(channelId);
return { unreadChannels: newUnread };
@@ -227,10 +232,13 @@ export const useChatStore = create((set, get) => ({
},
ackChannel: (channelId) => {
const msgs = get().messages.get(channelId);
if (!msgs || msgs.length === 0) return;
if (!msgs || msgs.length === 0)
return;
const lastMsg = msgs[msgs.length - 1];
if (!lastMsg) return;
if (!lastMsg)
return;
const messageId = lastMsg.id;
// Update local state immediately
set((state) => {
const newReadStates = new Map(state.readStates);
newReadStates.set(channelId, messageId);
@@ -238,6 +246,7 @@ export const useChatStore = create((set, get) => ({
newUnread.delete(channelId);
return { readStates: newReadStates, unreadChannels: newUnread };
});
// Send to server
wsSend({ type: 'channel_ack', channelId, messageId });
},
onChannelAck: (channelId, messageId) => {
+7 -1
View File
@@ -140,6 +140,7 @@ export const useServerStore = create((set, get) => ({
inviteCode: s.inviteCode,
createdAt: s.createdAt,
}));
// Build channel→server map and channel→lastMessageId map
const channelToServerMap = new Map();
const channelLastMessageIds = new Map();
for (const srv of servers) {
@@ -150,6 +151,7 @@ export const useServerStore = create((set, get) => ({
}
}
}
// Also map DM channels
const dms = dmChannels || [];
for (const dm of dms) {
if (dm.lastMessage?.id) {
@@ -165,7 +167,11 @@ export const useServerStore = create((set, get) => ({
});
},
}));
/**
* Data-driven DM channel detection. Returns true if the given channelId
* belongs to a DM channel. Authoritative because dmChannels is populated
* from the WS ready event and DM/server channel IDs never overlap.
*/
export function isDmChannel(channelId) {
const dmChannels = useServerStore.getState().dmChannels;
if (dmChannels.length > 0) {
+5
View File
@@ -29,4 +29,9 @@ export const useUIStore = create((set) => ({
closeUserProfile: () => set({
userProfilePopout: { user: null, position: null }
}),
voiceChatOpen: false,
voiceFullscreen: false,
toggleVoiceChat: () => set((state) => ({ voiceChatOpen: !state.voiceChatOpen })),
toggleVoiceFullscreen: () => set((state) => ({ voiceFullscreen: !state.voiceFullscreen })),
setVoiceFullscreen: (fullscreen) => set({ voiceFullscreen: fullscreen }),
}));
+11
View File
@@ -34,6 +34,11 @@ interface UIState {
closeImagePreview: () => void;
openUserProfile: (user: User, position: { top: number; left: number }) => void;
closeUserProfile: () => void;
voiceChatOpen: boolean;
voiceFullscreen: boolean;
toggleVoiceChat: () => void;
toggleVoiceFullscreen: () => void;
setVoiceFullscreen: (fullscreen: boolean) => void;
}
export const useUIStore = create<UIState>((set) => ({
@@ -72,4 +77,10 @@ export const useUIStore = create<UIState>((set) => ({
closeUserProfile: () => set({
userProfilePopout: { user: null, position: null }
}),
voiceChatOpen: false,
voiceFullscreen: false,
toggleVoiceChat: () => set((state) => ({ voiceChatOpen: !state.voiceChatOpen })),
toggleVoiceFullscreen: () => set((state) => ({ voiceFullscreen: !state.voiceFullscreen })),
setVoiceFullscreen: (fullscreen) => set({ voiceFullscreen: fullscreen }),
}));
+35
View File
@@ -9,6 +9,25 @@ export const useVoiceStore = create((set, get) => ({
participants: [],
connectionError: null,
isLiveKitConnected: false,
inputVolume: 100,
outputVolume: 100,
focusedParticipantId: null,
videoQuality: 'auto',
participantVolumes: new Map(),
setParticipantVolume: (userId, volume) => {
set((state) => {
const newMap = new Map(state.participantVolumes);
newMap.set(userId, volume);
return { participantVolumes: newMap };
});
},
getParticipantVolume: (userId) => get().participantVolumes.get(userId) ?? 100,
incomingCall: null,
outgoingCall: null,
activeDmCall: null,
setIncomingCall: (call) => set({ incomingCall: call }),
setOutgoingCall: (call) => set({ outgoingCall: call }),
setActiveDmCall: (call) => set({ activeDmCall: call }),
setVoiceUsers: (channelId, userIds) => {
set((state) => {
const newMap = new Map(state.voiceUsers);
@@ -38,10 +57,14 @@ export const useVoiceStore = create((set, get) => ({
setParticipants: (participants) => set({ participants }),
setConnectionError: (error) => set({ connectionError: error }),
setIsLiveKitConnected: (connected) => set({ isLiveKitConnected: connected }),
setInputVolume: (volume) => set({ inputVolume: volume }),
setOutputVolume: (volume) => set({ outputVolume: volume }),
toggleMic: () => set((state) => ({ isMuted: !state.isMuted })),
toggleDeafen: () => set((state) => ({ isDeafened: !state.isDeafened })),
toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })),
toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })),
setFocusedParticipant: (id) => set({ focusedParticipantId: id }),
setVideoQuality: (quality) => set({ videoQuality: quality }),
getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [],
clearAllVoiceUsers: () => set({ voiceUsers: new Map() }),
// Leave voice without wiping the voiceUsers map (so sidebar still shows others)
@@ -54,6 +77,11 @@ export const useVoiceStore = create((set, get) => ({
participants: [],
connectionError: null,
isLiveKitConnected: false,
inputVolume: 100,
outputVolume: 100,
focusedParticipantId: null,
activeDmCall: null,
outgoingCall: null,
}),
reset: () => set({
voiceUsers: new Map(),
@@ -65,5 +93,12 @@ export const useVoiceStore = create((set, get) => ({
participants: [],
connectionError: null,
isLiveKitConnected: false,
inputVolume: 100,
outputVolume: 100,
focusedParticipantId: null,
participantVolumes: new Map(),
incomingCall: null,
outgoingCall: null,
activeDmCall: null,
}),
}));
+45
View File
@@ -13,6 +13,19 @@ interface VoiceState {
isLiveKitConnected: boolean;
inputVolume: number; // 0-200 (100 = default)
outputVolume: number; // 0-200 (100 = default)
focusedParticipantId: string | null;
videoQuality: 'auto' | '1080p' | '1080p60' | '720p' | '720p60' | '540p' | '360p';
// Per-participant volume (userId → 0-200, 100 = default)
participantVolumes: Map<string, number>;
setParticipantVolume: (userId: string, volume: number) => void;
getParticipantVolume: (userId: string) => number;
// DM call state
incomingCall: { dmChannelId: string; callerId: string; callerName: string } | null;
outgoingCall: { dmChannelId: string } | null;
activeDmCall: { dmChannelId: string } | null;
setIncomingCall: (call: { dmChannelId: string; callerId: string; callerName: string } | null) => void;
setOutgoingCall: (call: { dmChannelId: string } | null) => void;
setActiveDmCall: (call: { dmChannelId: string } | null) => void;
setVoiceUsers: (channelId: string, userIds: string[]) => void;
addVoiceUser: (channelId: string, userId: string) => void;
removeVoiceUser: (channelId: string, userId: string) => void;
@@ -26,6 +39,8 @@ interface VoiceState {
toggleCamera: () => void;
toggleScreenShare: () => void;
toggleDeafen: () => void;
setFocusedParticipant: (id: string | null) => void;
setVideoQuality: (quality: 'auto' | '1080p' | '1080p60' | '720p' | '720p60' | '540p' | '360p') => void;
getVoiceUsers: (channelId: string) => string[];
clearAllVoiceUsers: () => void;
leaveVoice: () => void;
@@ -44,6 +59,25 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
isLiveKitConnected: false,
inputVolume: 100,
outputVolume: 100,
focusedParticipantId: null,
videoQuality: 'auto',
participantVolumes: new Map(),
setParticipantVolume: (userId, volume) => {
set((state) => {
const newMap = new Map(state.participantVolumes);
newMap.set(userId, volume);
return { participantVolumes: newMap };
});
},
getParticipantVolume: (userId) => get().participantVolumes.get(userId) ?? 100,
incomingCall: null,
outgoingCall: null,
activeDmCall: null,
setIncomingCall: (call) => set({ incomingCall: call }),
setOutgoingCall: (call) => set({ outgoingCall: call }),
setActiveDmCall: (call) => set({ activeDmCall: call }),
setVoiceUsers: (channelId, userIds) => {
set((state) => {
@@ -87,6 +121,9 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })),
toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })),
setFocusedParticipant: (id) => set({ focusedParticipantId: id }),
setVideoQuality: (quality) => set({ videoQuality: quality }),
getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [],
clearAllVoiceUsers: () => set({ voiceUsers: new Map() }),
@@ -103,6 +140,9 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
isLiveKitConnected: false,
inputVolume: 100,
outputVolume: 100,
focusedParticipantId: null,
activeDmCall: null,
outgoingCall: null,
}),
reset: () => set({
@@ -117,5 +157,10 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
isLiveKitConnected: false,
inputVolume: 100,
outputVolume: 100,
focusedParticipantId: null,
participantVolumes: new Map(),
incomingCall: null,
outgoingCall: null,
activeDmCall: null,
}),
}));
+4
View File
@@ -8,6 +8,10 @@
outline: none !important;
}
::selection {
background: rgba(88, 101, 242, 0.3);
}
html, body, #root {
@apply h-full w-full overflow-hidden bg-discord-bg-tertiary text-discord-text-normal;
font-family: 'Inter', 'gg sans', 'Noto Sans', sans-serif;
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';
+1 -1
View File
@@ -10,7 +10,7 @@ export default {
discord: {
// Backgrounds (darkest → lightest)
'bg-tertiary': '#1e1f22',
'bg-server': '#25262a',
'bg-server': '#1e1f22',
'bg-secondary': '#2b2d31',
'bg-primary': '#313338',
'bg-input': '#383a40',