feat: DM infrastructure overhaul + volume sliders + LiveKit dynamic URL
- Add DM edit/delete endpoints (REST + WebSocket) - Add DM typing indicators with real-time broadcast - Fix volume sliders to control LiveKit mic/speaker - Fix Send Message button on user profile popout - Add New DM modal with user search - Fix message pagination (SQL cursor instead of in-memory) - Guard optional attachments on DM messages - Dynamic LiveKit URL from request Host header - DM sidebar auto-sorts by most recent message
This commit is contained in:
@@ -117,6 +117,15 @@ function createTables(db: Database.Database): void {
|
||||
UNIQUE(message_id, user_id, emoji)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dm_reactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
dm_message_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(dm_message_id, user_id, emoji)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -22,6 +22,19 @@ export function runMigrations(db: Database.Database): void {
|
||||
columns: [
|
||||
{ name: 'permissions', type: 'TEXT' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'dm_messages',
|
||||
columns: [
|
||||
{ name: 'edited_at', type: 'INTEGER' },
|
||||
{ name: 'reply_to_id', type: 'TEXT' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'attachments',
|
||||
columns: [
|
||||
{ name: 'dm_message_id', type: 'TEXT' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export const messages = sqliteTable('messages', {
|
||||
export const attachments = sqliteTable('attachments', {
|
||||
id: text('id').primaryKey(),
|
||||
messageId: text('message_id').references(() => messages.id, { onDelete: 'cascade' }),
|
||||
dmMessageId: text('dm_message_id'),
|
||||
filename: text('filename').notNull(),
|
||||
originalName: text('original_name').notNull(),
|
||||
mimetype: text('mimetype').notNull(),
|
||||
@@ -81,7 +82,9 @@ export const dmMessages = sqliteTable('dm_messages', {
|
||||
id: text('id').primaryKey(),
|
||||
dmChannelId: text('dm_channel_id').notNull().references(() => dmChannels.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
replyToId: text('reply_to_id'),
|
||||
content: text('content'),
|
||||
editedAt: integer('edited_at'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
@@ -109,6 +112,14 @@ export const reactions = sqliteTable('reactions', {
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const dmReactions = sqliteTable('dm_reactions', {
|
||||
id: text('id').primaryKey(),
|
||||
dmMessageId: text('dm_message_id').notNull(),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
emoji: text('emoji').notNull(),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const roles = sqliteTable('roles', {
|
||||
id: text('id').primaryKey(),
|
||||
serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and, desc, inArray } from 'drizzle-orm';
|
||||
import { eq, and, desc, lt, inArray } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
@@ -147,11 +147,26 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
const memberUserIds = dmMemberRows.map(m => m.userId);
|
||||
const users = db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all();
|
||||
|
||||
// Fetch actual last message
|
||||
const lastMsgRows = db.select()
|
||||
.from(schema.dmMessages)
|
||||
.where(eq(schema.dmMessages.dmChannelId, myDm.dmChannelId))
|
||||
.orderBy(desc(schema.dmMessages.createdAt))
|
||||
.limit(1)
|
||||
.all();
|
||||
const lastMsg = lastMsgRows[0] ?? null;
|
||||
|
||||
const result: DmChannel = {
|
||||
id: dmChannel.id,
|
||||
createdAt: dmChannel.createdAt,
|
||||
members: users.map(sanitizeUser),
|
||||
lastMessage: null,
|
||||
lastMessage: lastMsg ? {
|
||||
id: lastMsg.id,
|
||||
dmChannelId: lastMsg.dmChannelId,
|
||||
userId: lastMsg.userId,
|
||||
content: lastMsg.content,
|
||||
createdAt: lastMsg.createdAt,
|
||||
} : null,
|
||||
};
|
||||
|
||||
return reply.code(200).send(result);
|
||||
@@ -211,11 +226,13 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
if (before) {
|
||||
messageRows = db.select()
|
||||
.from(schema.dmMessages)
|
||||
.where(eq(schema.dmMessages.dmChannelId, id))
|
||||
.where(and(
|
||||
eq(schema.dmMessages.dmChannelId, id),
|
||||
lt(schema.dmMessages.id, before)
|
||||
))
|
||||
.orderBy(desc(schema.dmMessages.createdAt))
|
||||
.all()
|
||||
.filter(m => m.id < before)
|
||||
.slice(0, limit);
|
||||
.limit(limit)
|
||||
.all();
|
||||
} else {
|
||||
messageRows = db.select()
|
||||
.from(schema.dmMessages)
|
||||
@@ -310,4 +327,111 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
return reply.code(201).send(message);
|
||||
});
|
||||
|
||||
// PATCH /api/dm/messages/:id - Edit a DM message
|
||||
app.patch<{ Params: { id: string }; Body: { content: string } }>('/api/dm/messages/:id', {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { content } = request.body;
|
||||
|
||||
if (!content || typeof content !== 'string' || content.trim().length === 0) {
|
||||
return reply.code(400).send({ error: 'Message content is required', statusCode: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const msg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, id)).get();
|
||||
if (!msg) {
|
||||
return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
if (msg.userId !== request.userId) {
|
||||
return reply.code(403).send({ error: 'You can only edit your own messages', statusCode: 403 });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
db.update(schema.dmMessages)
|
||||
.set({ content: content.trim(), editedAt: now })
|
||||
.where(eq(schema.dmMessages.id, id))
|
||||
.run();
|
||||
|
||||
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||
if (!user) {
|
||||
return reply.code(500).send({ error: 'User not found', statusCode: 500 });
|
||||
}
|
||||
|
||||
const updated: DmMessageWithUser = {
|
||||
id: msg.id,
|
||||
dmChannelId: msg.dmChannelId,
|
||||
userId: msg.userId,
|
||||
content: content.trim(),
|
||||
editedAt: now,
|
||||
createdAt: msg.createdAt,
|
||||
user: sanitizeUser(user),
|
||||
};
|
||||
|
||||
// Broadcast to all DM members
|
||||
const dmMembers = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, msg.dmChannelId))
|
||||
.all();
|
||||
|
||||
for (const member of dmMembers) {
|
||||
connectionManager.sendToUser(member.userId, {
|
||||
type: 'dm_message_updated',
|
||||
message: updated,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(200).send(updated);
|
||||
});
|
||||
|
||||
// DELETE /api/dm/messages/:id - Delete a DM message
|
||||
app.delete<{ Params: { id: string } }>('/api/dm/messages/:id', {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const db = getDb();
|
||||
|
||||
const msg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, id)).get();
|
||||
if (!msg) {
|
||||
return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
if (msg.userId !== request.userId) {
|
||||
return reply.code(403).send({ error: 'You can only delete your own messages', statusCode: 403 });
|
||||
}
|
||||
|
||||
// Delete attachments linked to this DM message
|
||||
db.delete(schema.attachments)
|
||||
.where(eq(schema.attachments.dmMessageId, id))
|
||||
.run();
|
||||
|
||||
// Delete reactions
|
||||
db.delete(schema.dmReactions)
|
||||
.where(eq(schema.dmReactions.dmMessageId, id))
|
||||
.run();
|
||||
|
||||
// Delete message
|
||||
db.delete(schema.dmMessages)
|
||||
.where(eq(schema.dmMessages.id, id))
|
||||
.run();
|
||||
|
||||
// Broadcast to all DM members
|
||||
const dmMembers = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, msg.dmChannelId))
|
||||
.all();
|
||||
|
||||
for (const member of dmMembers) {
|
||||
connectionManager.sendToUser(member.userId, {
|
||||
type: 'dm_message_deleted',
|
||||
messageId: id,
|
||||
dmChannelId: msg.dmChannelId,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,9 +41,16 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const jwt = await token.toJwt();
|
||||
|
||||
const response: LiveKitTokenResponse = {
|
||||
// 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`
|
||||
: config.livekit.url;
|
||||
|
||||
const response: LiveKitTokenResponse = {
|
||||
token: jwt,
|
||||
url: config.livekit.url
|
||||
url: livekitUrl
|
||||
};
|
||||
return reply.code(200).send(response);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, desc, inArray } from 'drizzle-orm';
|
||||
import { eq, and, desc, lt, inArray } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
@@ -186,11 +186,13 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
||||
if (before) {
|
||||
messageRows = db.select()
|
||||
.from(schema.messages)
|
||||
.where(eq(schema.messages.channelId, id))
|
||||
.where(and(
|
||||
eq(schema.messages.channelId, id),
|
||||
lt(schema.messages.id, before)
|
||||
))
|
||||
.orderBy(desc(schema.messages.createdAt))
|
||||
.all()
|
||||
.filter(m => m.id < before)
|
||||
.slice(0, limit);
|
||||
.limit(limit)
|
||||
.all();
|
||||
} else {
|
||||
messageRows = db.select()
|
||||
.from(schema.messages)
|
||||
|
||||
@@ -126,6 +126,15 @@ export function handleClientEvent(
|
||||
case 'dm_message_create':
|
||||
handleDmMessageCreate(event, userId);
|
||||
break;
|
||||
case 'dm_typing_start':
|
||||
handleDmTypingStart(event, userId, username);
|
||||
break;
|
||||
case 'dm_message_edit':
|
||||
handleDmMessageEdit(event, userId);
|
||||
break;
|
||||
case 'dm_message_delete':
|
||||
handleDmMessageDelete(event, userId);
|
||||
break;
|
||||
case 'reaction_add':
|
||||
handleReactionAdd(event, userId);
|
||||
break;
|
||||
@@ -455,6 +464,150 @@ function handleDmMessageCreate(event: Record<string, unknown>, userId: string):
|
||||
}
|
||||
}
|
||||
|
||||
function handleDmTypingStart(event: Record<string, unknown>, userId: string, username: string): void {
|
||||
const dmChannelId = event.dmChannelId as string;
|
||||
|
||||
if (!dmChannelId || typeof dmChannelId !== 'string') return;
|
||||
|
||||
if (!isDmMember(dmChannelId, userId)) return;
|
||||
|
||||
const key = `dm:${userId}:${dmChannelId}`;
|
||||
const existing = typingTimeouts.get(key);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
}
|
||||
|
||||
// Send to all other DM members
|
||||
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_typing',
|
||||
dmChannelId,
|
||||
userId,
|
||||
username,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
typingTimeouts.delete(key);
|
||||
}, 5000);
|
||||
typingTimeouts.set(key, timeout);
|
||||
}
|
||||
|
||||
function handleDmMessageEdit(event: Record<string, unknown>, userId: string): void {
|
||||
const messageId = event.messageId as string;
|
||||
const content = event.content as string;
|
||||
|
||||
if (!messageId || typeof messageId !== 'string') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'messageId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content || typeof content !== 'string' || content.trim().length === 0) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'content is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const msg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, messageId)).get();
|
||||
if (!msg) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Message not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.userId !== userId) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'You can only edit your own messages' });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
db.update(schema.dmMessages)
|
||||
.set({ content: content.trim(), editedAt: now })
|
||||
.where(eq(schema.dmMessages.id, messageId))
|
||||
.run();
|
||||
|
||||
const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
|
||||
if (!user) return;
|
||||
|
||||
const updated: DmMessageWithUser = {
|
||||
id: msg.id,
|
||||
dmChannelId: msg.dmChannelId,
|
||||
userId: msg.userId,
|
||||
content: content.trim(),
|
||||
editedAt: now,
|
||||
createdAt: msg.createdAt,
|
||||
user: sanitizeUser(user),
|
||||
};
|
||||
|
||||
const dmMembers = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, msg.dmChannelId))
|
||||
.all();
|
||||
|
||||
for (const member of dmMembers) {
|
||||
connectionManager.sendToUser(member.userId, {
|
||||
type: 'dm_message_updated',
|
||||
message: updated,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleDmMessageDelete(event: Record<string, unknown>, userId: string): void {
|
||||
const messageId = event.messageId as string;
|
||||
|
||||
if (!messageId || typeof messageId !== 'string') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'messageId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const msg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, messageId)).get();
|
||||
if (!msg) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Message not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.userId !== userId) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'You can only delete your own messages' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete attachments linked to this DM message
|
||||
db.delete(schema.attachments)
|
||||
.where(eq(schema.attachments.dmMessageId, messageId))
|
||||
.run();
|
||||
|
||||
// Delete reactions
|
||||
db.delete(schema.dmReactions)
|
||||
.where(eq(schema.dmReactions.dmMessageId, messageId))
|
||||
.run();
|
||||
|
||||
// Delete message
|
||||
db.delete(schema.dmMessages)
|
||||
.where(eq(schema.dmMessages.id, messageId))
|
||||
.run();
|
||||
|
||||
const dmMembers = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, msg.dmChannelId))
|
||||
.all();
|
||||
|
||||
for (const member of dmMembers) {
|
||||
connectionManager.sendToUser(member.userId, {
|
||||
type: 'dm_message_deleted',
|
||||
messageId,
|
||||
dmChannelId: msg.dmChannelId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleReactionAdd(event: Record<string, unknown>, userId: string): void {
|
||||
const messageId = event.messageId as string;
|
||||
const emoji = event.emoji as string;
|
||||
|
||||
@@ -187,6 +187,7 @@ function buildReadyPayload(userId: string): {
|
||||
servers: ServerWithChannelsAndMembers[];
|
||||
dmChannels: DmChannel[];
|
||||
folders: ServerFolder[];
|
||||
voiceStates: Record<string, string[]>;
|
||||
} {
|
||||
const db = getDb();
|
||||
|
||||
|
||||
@@ -169,13 +169,16 @@ export type ClientEvent =
|
||||
| { type: 'presence_update'; status: 'online' | 'idle' | 'dnd' }
|
||||
| { type: 'voice_join'; channelId: string }
|
||||
| { type: 'voice_leave' }
|
||||
| { type: 'dm_message_create'; dmChannelId: string; content: string }
|
||||
| { type: 'dm_message_create'; dmChannelId: string; content: string; replyToId?: string }
|
||||
| { type: 'dm_typing_start'; dmChannelId: string }
|
||||
| { type: 'dm_message_edit'; messageId: string; content: string }
|
||||
| { type: 'dm_message_delete'; messageId: string }
|
||||
| { type: 'reaction_add'; messageId: string; emoji: string }
|
||||
| { type: 'reaction_remove'; messageId: string; emoji: string };
|
||||
|
||||
// Server → Client Events
|
||||
export type ServerEvent =
|
||||
| { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[] }
|
||||
| { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[]; voiceStates?: Record<string, string[]> }
|
||||
| { type: 'message_created'; message: MessageWithUser }
|
||||
| { type: 'message_updated'; message: MessageWithUser }
|
||||
| { type: 'message_deleted'; messageId: string; channelId: string }
|
||||
@@ -185,6 +188,9 @@ export type ServerEvent =
|
||||
| { type: 'member_joined'; serverId: string; member: MemberWithUser }
|
||||
| { type: 'member_left'; serverId: string; userId: string }
|
||||
| { type: 'dm_message_created'; message: DmMessageWithUser }
|
||||
| { type: 'dm_message_updated'; message: DmMessageWithUser }
|
||||
| { type: 'dm_message_deleted'; messageId: string; dmChannelId: string }
|
||||
| { type: 'dm_typing'; dmChannelId: string; userId: string; username: string }
|
||||
| { type: 'reaction_added'; messageId: string; reaction: Reaction }
|
||||
| { type: 'reaction_removed'; messageId: string; userId: string; emoji: string }
|
||||
| { type: 'friend_request_received'; request: FriendRequest }
|
||||
|
||||
@@ -99,6 +99,8 @@ export const api = {
|
||||
return request('GET', `/dm/${id}/messages?${params}`);
|
||||
},
|
||||
sendMessage: (id, data) => request('POST', `/dm/${id}/messages`, data),
|
||||
updateMessage: (id, data) => request('PATCH', `/dm/messages/${id}`, data),
|
||||
deleteMessage: (id) => request('DELETE', `/dm/messages/${id}`),
|
||||
},
|
||||
social: {
|
||||
friends: () => request('GET', '/social/friends'),
|
||||
|
||||
@@ -157,6 +157,10 @@ export const api = {
|
||||
},
|
||||
sendMessage: (id: string, data: CreateDmMessageRequest) =>
|
||||
request<DmMessageWithUser>('POST', `/dm/${id}/messages`, data),
|
||||
updateMessage: (id: string, data: UpdateMessageRequest) =>
|
||||
request<DmMessageWithUser>('PATCH', `/dm/messages/${id}`, data),
|
||||
deleteMessage: (id: string) =>
|
||||
request<{ success: boolean }>('DELETE', `/dm/messages/${id}`),
|
||||
},
|
||||
|
||||
social: {
|
||||
|
||||
@@ -109,10 +109,10 @@ export function Message({ message, isCompact, isFirstInGroup }) {
|
||||
return { color: member.roles[0].color };
|
||||
}
|
||||
if (member?.role === 'owner')
|
||||
return { color: '#da373c' };
|
||||
return { color: '#f23f43' };
|
||||
if (member?.role === 'admin')
|
||||
return { color: '#5865f2' };
|
||||
return { color: '#dbdee1' };
|
||||
return { color: '#dcdcdf' };
|
||||
})();
|
||||
const replyRoleColor = (msg) => {
|
||||
const member = members.find(m => m.userId === msg.userId);
|
||||
@@ -120,12 +120,12 @@ export function Message({ message, isCompact, isFirstInGroup }) {
|
||||
return { color: member.roles[0].color };
|
||||
}
|
||||
if (member?.role === 'owner')
|
||||
return { color: '#da373c' };
|
||||
return { color: '#f23f43' };
|
||||
if (member?.role === 'admin')
|
||||
return { color: '#5865f2' };
|
||||
return { color: '#dbdee1' };
|
||||
return { color: '#dcdcdf' };
|
||||
};
|
||||
const content = (_jsxs("div", { className: `group relative flex px-4 py-0.5 hover:bg-[#2e3035]/30 transition-colors ${isFirstInGroup || message.replyTo ? 'mt-[1.0625rem]' : ''}`, onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [message.replyTo && (_jsx("div", { className: "absolute left-[36px] top-[-14px] w-[33px] h-[22px] border-l-2 border-t-2 border-[#4e5058] rounded-tl-[6px] opacity-60" })), _jsx("div", { className: "w-[72px] flex-shrink-0 flex items-start justify-start pl-0.5", children: isFirstInGroup || message.replyTo ? (_jsx("div", { className: "mt-1", children: _jsx(Avatar, { src: message.user.avatar, name: displayName, size: 40, user: message.user, className: "hover:drop-shadow-md transition-all active:translate-y-[1px]" }) })) : (_jsx("span", { className: `text-[11px] text-discord-text-muted opacity-0 group-hover:opacity-100 mt-2 select-none w-full text-center leading-[1.375rem] font-medium`, children: formatHoverTime(message.createdAt) })) }), _jsxs("div", { className: "flex-1 min-w-0 pr-4", children: [message.replyTo && (_jsxs("div", { className: "flex items-center gap-1 mb-1 ml-[-4px] opacity-80 hover:opacity-100 cursor-pointer group/reply", children: [_jsx(Avatar, { src: message.replyTo.user.avatar, name: message.replyTo.user.username, size: 16 }), _jsx("span", { className: "text-[14px] font-bold text-discord-text-header hover:underline", style: message.replyTo ? replyRoleColor(message.replyTo) : undefined, children: message.replyTo.user.displayName ?? message.replyTo.user.username }), _jsx("span", { className: "text-[14px] text-discord-text-normal truncate max-w-[400px] hover:text-white", children: message.replyTo.content })] })), (isFirstInGroup || message.replyTo) && (_jsxs("div", { className: "flex items-baseline gap-2 mb-0.5", children: [_jsx("span", { onClick: handleUsernameClick, className: "font-bold cursor-pointer hover:underline text-[16px] leading-tight", style: roleColor, children: displayName }), _jsx("span", { className: "text-[12px] text-discord-text-muted leading-tight font-medium hover:cursor-default", children: formatTime(message.createdAt) })] })), isEditing ? (_jsxs("div", { className: "mt-1 w-full", children: [_jsx("textarea", { value: editContent, onChange: (e) => setEditContent(e.target.value), onKeyDown: handleEditSubmit, className: "w-full p-3 bg-discord-bg-input rounded-lg text-discord-text-primary outline-none resize-none text-[16px] leading-[1.375rem] shadow-inner", rows: 2, autoFocus: true }), _jsxs("p", { className: "text-[12px] text-discord-text-muted mt-1.5 ml-1", children: ["escape to ", _jsx("button", { onClick: () => setIsEditing(false), className: "text-discord-text-link hover:underline", children: "cancel" }), ' ', "\u2022 enter to ", _jsx("button", { onClick: () => {
|
||||
const content = (_jsxs("div", { className: `group relative flex px-4 py-0.5 hover:bg-discord-modifier-hover transition-colors ${isFirstInGroup || message.replyTo ? 'mt-[1.0625rem]' : ''}`, onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [message.replyTo && (_jsx("div", { className: "absolute left-[36px] top-[-14px] w-[33px] h-[22px] border-l-2 border-t-2 border-discord-interactive-muted rounded-tl-[6px] opacity-60" })), _jsx("div", { className: "w-[72px] flex-shrink-0 flex items-start justify-start pl-0.5", children: isFirstInGroup || message.replyTo ? (_jsx("div", { className: "mt-1", children: _jsx(Avatar, { src: message.user.avatar, name: displayName, size: 40, user: message.user, className: "hover:drop-shadow-md transition-all active:translate-y-[1px]" }) })) : (_jsx("span", { className: `text-[11px] text-discord-text-muted opacity-0 group-hover:opacity-100 mt-2 select-none w-full text-center leading-[1.375rem] font-medium`, children: formatHoverTime(message.createdAt) })) }), _jsxs("div", { className: "flex-1 min-w-0 pr-4", children: [message.replyTo && (_jsxs("div", { className: "flex items-center gap-1 mb-1 ml-[-4px] opacity-80 hover:opacity-100 cursor-pointer group/reply", children: [_jsx(Avatar, { src: message.replyTo.user.avatar, name: message.replyTo.user.username, size: 16 }), _jsx("span", { className: "text-[14px] font-bold text-discord-text-header hover:underline", style: message.replyTo ? replyRoleColor(message.replyTo) : undefined, children: message.replyTo.user.displayName ?? message.replyTo.user.username }), _jsx("span", { className: "text-[14px] text-discord-text-normal truncate max-w-[400px] hover:text-discord-text-primary", children: message.replyTo.content })] })), (isFirstInGroup || message.replyTo) && (_jsxs("div", { className: "flex items-baseline gap-2 mb-0.5", children: [_jsx("span", { onClick: handleUsernameClick, className: "font-bold cursor-pointer hover:underline text-[16px] leading-tight", style: roleColor, children: displayName }), _jsx("span", { className: "text-[12px] text-discord-text-muted leading-tight font-medium hover:cursor-default", children: formatTime(message.createdAt) })] })), isEditing ? (_jsxs("div", { className: "mt-1 w-full", children: [_jsx("textarea", { value: editContent, onChange: (e) => setEditContent(e.target.value), onKeyDown: handleEditSubmit, className: "w-full p-3 bg-discord-bg-input rounded-lg text-discord-text-primary outline-none resize-none text-[16px] leading-[1.375rem] shadow-inner", rows: 2, autoFocus: true }), _jsxs("p", { className: "text-[12px] text-discord-text-muted mt-1.5 ml-1", children: ["escape to ", _jsx("button", { onClick: () => setIsEditing(false), className: "text-discord-text-link hover:underline", children: "cancel" }), ' ', "\u2022 enter to ", _jsx("button", { onClick: () => {
|
||||
if (editContent.trim()) {
|
||||
editMessage(message.id, editContent.trim());
|
||||
setIsEditing(false);
|
||||
@@ -139,7 +139,7 @@ export function Message({ message, isCompact, isFirstInGroup }) {
|
||||
em: ({ children }) => _jsx("em", { className: "italic", children: children }),
|
||||
}, children: message.content }), message.editedAt && (_jsx("span", { className: "text-[10px] text-discord-text-muted ml-1 select-none font-medium", children: "(edited)" }))] })), Object.keys(reactionGroups).length > 0 && (_jsx("div", { className: "flex flex-wrap gap-1 mt-1", children: Object.entries(reactionGroups).map(([emoji, { count, me }]) => (_jsxs("button", { onClick: () => toggleReaction(emoji), className: `flex items-center gap-1.5 px-1.5 py-0.5 rounded-[8px] text-[14px] font-medium border transition-colors ${me
|
||||
? 'bg-discord-blurple/15 border-discord-blurple text-discord-blurple'
|
||||
: 'bg-discord-bg-secondary border-transparent text-discord-text-muted hover:border-discord-text-muted/30'}`, children: [_jsx("span", { children: emoji }), _jsx("span", { className: me ? 'text-discord-blurple' : 'text-discord-text-normal', children: count })] }, emoji))) })), !isEditing && firstUrl && _jsx(Embed, { url: firstUrl }), message.attachments.length > 0 && (_jsx("div", { className: "mt-1 grid gap-2", children: message.attachments.map((att) => {
|
||||
: 'bg-discord-bg-secondary border-transparent text-discord-text-muted hover:border-discord-text-muted/30'}`, children: [_jsx("span", { children: emoji }), _jsx("span", { className: me ? 'text-discord-blurple' : 'text-discord-text-normal', children: count })] }, emoji))) })), !isEditing && firstUrl && _jsx(Embed, { url: firstUrl }), message.attachments && message.attachments.length > 0 && (_jsx("div", { className: "mt-1 grid gap-2", children: message.attachments.map((att) => {
|
||||
const isImage = att.mimetype.startsWith('image/');
|
||||
if (isImage) {
|
||||
return (_jsx("div", { className: "max-w-fit mt-1 rounded-lg overflow-hidden border border-discord-bg-tertiary/50 bg-discord-bg-tertiary/20", children: _jsx("img", { src: `/api/uploads/${att.filename}`, alt: att.originalName, className: "max-w-full max-h-[350px] object-contain cursor-pointer hover:brightness-95 transition-all", onClick: () => openImagePreview(`/api/uploads/${att.filename}`), loading: "lazy" }) }, att.id));
|
||||
|
||||
@@ -125,9 +125,9 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
if (member?.roles && member.roles.length > 0) {
|
||||
return { color: member.roles[0]!.color };
|
||||
}
|
||||
if (member?.role === 'owner') return { color: '#da373c' };
|
||||
if (member?.role === 'owner') return { color: '#f23f43' };
|
||||
if (member?.role === 'admin') return { color: '#5865f2' };
|
||||
return { color: '#dbdee1' };
|
||||
return { color: '#dcdcdf' };
|
||||
})();
|
||||
|
||||
const replyRoleColor = (msg: any) => {
|
||||
@@ -135,20 +135,20 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
if (member?.roles && member.roles.length > 0) {
|
||||
return { color: member.roles[0]!.color };
|
||||
}
|
||||
if (member?.role === 'owner') return { color: '#da373c' };
|
||||
if (member?.role === 'owner') return { color: '#f23f43' };
|
||||
if (member?.role === 'admin') return { color: '#5865f2' };
|
||||
return { color: '#dbdee1' };
|
||||
return { color: '#dcdcdf' };
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`group relative flex px-4 py-0.5 hover:bg-[#2e3035]/30 transition-colors ${isFirstInGroup || message.replyTo ? 'mt-[1.0625rem]' : ''}`}
|
||||
className={`group relative flex px-4 py-0.5 hover:bg-discord-modifier-hover transition-colors ${isFirstInGroup || message.replyTo ? 'mt-[1.0625rem]' : ''}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Reply Line */}
|
||||
{message.replyTo && (
|
||||
<div className="absolute left-[36px] top-[-14px] w-[33px] h-[22px] border-l-2 border-t-2 border-[#4e5058] rounded-tl-[6px] opacity-60" />
|
||||
<div className="absolute left-[36px] top-[-14px] w-[33px] h-[22px] border-l-2 border-t-2 border-discord-interactive-muted rounded-tl-[6px] opacity-60" />
|
||||
)}
|
||||
|
||||
{/* Avatar or timestamp column */}
|
||||
@@ -181,7 +181,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
>
|
||||
{message.replyTo.user.displayName ?? message.replyTo.user.username}
|
||||
</span>
|
||||
<span className="text-[14px] text-discord-text-normal truncate max-w-[400px] hover:text-white">
|
||||
<span className="text-[14px] text-discord-text-normal truncate max-w-[400px] hover:text-discord-text-primary">
|
||||
{message.replyTo.content}
|
||||
</span>
|
||||
</div>
|
||||
@@ -280,7 +280,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
{!isEditing && firstUrl && <Embed url={firstUrl} />}
|
||||
|
||||
{/* Attachments */}
|
||||
{message.attachments.length > 0 && (
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="mt-1 grid gap-2">
|
||||
{message.attachments.map((att) => {
|
||||
const isImage = att.mimetype.startsWith('image/');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { api } from '../../api/client';
|
||||
export function MessageInput({ channelId, channelName }) {
|
||||
@@ -16,7 +17,12 @@ export function MessageInput({ channelId, channelName }) {
|
||||
const handleTyping = useCallback(() => {
|
||||
if (typingTimeoutRef.current)
|
||||
return;
|
||||
wsSend({ type: 'typing_start', channelId });
|
||||
const isDm = useUIStore.getState().showDms;
|
||||
if (isDm) {
|
||||
wsSend({ type: 'dm_typing_start', dmChannelId: channelId });
|
||||
} else {
|
||||
wsSend({ type: 'typing_start', channelId });
|
||||
}
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
typingTimeoutRef.current = undefined;
|
||||
}, 3000);
|
||||
@@ -91,11 +97,11 @@ export function MessageInput({ channelId, channelName }) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
|
||||
};
|
||||
return (_jsxs("div", { className: "px-4 pb-6 flex-shrink-0", children: [replyTo && (_jsxs("div", { className: "bg-[#2e3035] rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-discord-bg-tertiary/50", children: [_jsxs("div", { className: "flex items-center gap-1 text-[14px] text-discord-text-normal truncate", children: [_jsx("span", { className: "opacity-60", children: "Replying to" }), _jsx("span", { className: "font-bold", children: replyTo.user.displayName ?? replyTo.user.username })] }), _jsx("button", { onClick: () => setReplyTo(null), className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" }) }) })] })), _jsxs("div", { className: `bg-discord-bg-input ${replyTo ? 'rounded-b-lg' : 'rounded-lg'} overflow-hidden`, onDrop: handleDrop, onDragOver: handleDragOver, children: [files.length > 0 && (_jsx("div", { className: "p-4 flex flex-wrap gap-4 bg-discord-bg-secondary/30", children: files.map((file, i) => (_jsxs("div", { className: "relative group bg-discord-bg-secondary rounded-lg p-2 max-w-[200px] shadow-sm border border-discord-bg-tertiary", children: [file.type.startsWith('image/') ? (_jsx("img", { src: URL.createObjectURL(file), alt: file.name, className: "max-h-[150px] rounded object-cover" })) : (_jsxs("div", { className: "flex items-center gap-2 text-sm text-discord-text-secondary py-4 px-2", children: [_jsx("svg", { className: "w-8 h-8 opacity-60", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" }) }), _jsx("span", { className: "truncate max-w-[120px] font-medium", children: file.name })] })), _jsx("button", { onClick: () => removeFile(i), className: "absolute -top-2 -right-2 w-7 h-7 bg-discord-red hover:bg-discord-red-hover shadow-lg rounded-lg flex items-center justify-center text-white transition-colors z-10", children: _jsx("svg", { width: "14", height: "14", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" }) }) })] }, i))) })), _jsxs("div", { className: "flex items-start px-1", children: [_jsx("button", { onClick: () => fileInputRef.current?.click(), className: "p-3 text-discord-text-muted hover:text-discord-text-secondary transition-colors sticky top-0", title: "Attach file", children: _jsx("div", { className: "bg-discord-text-muted/20 hover:bg-discord-text-muted/40 rounded-full p-0.5 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 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" }) }) }) }), _jsx("input", { ref: fileInputRef, type: "file", multiple: true, className: "hidden", onChange: (e) => {
|
||||
return (_jsxs("div", { className: "px-4 pb-6 flex-shrink-0", children: [replyTo && (_jsxs("div", { className: "bg-discord-bg-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-discord-bg-tertiary/50", children: [_jsxs("div", { className: "flex items-center gap-1 text-[14px] text-discord-text-normal truncate", children: [_jsx("span", { className: "opacity-60", children: "Replying to" }), _jsx("span", { className: "font-bold", children: replyTo.user.displayName ?? replyTo.user.username })] }), _jsx("button", { onClick: () => setReplyTo(null), className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" }) }) })] })), _jsxs("div", { className: `bg-discord-bg-input ${replyTo ? 'rounded-b-lg' : 'rounded-lg'} overflow-hidden`, onDrop: handleDrop, onDragOver: handleDragOver, children: [files.length > 0 && (_jsx("div", { className: "p-4 flex flex-wrap gap-4 bg-discord-bg-secondary/30", children: files.map((file, i) => (_jsxs("div", { className: "relative group bg-discord-bg-secondary rounded-lg p-2 max-w-[200px] shadow-elevation-low border border-discord-bg-tertiary", children: [file.type.startsWith('image/') ? (_jsx("img", { src: URL.createObjectURL(file), alt: file.name, className: "max-h-[150px] rounded object-cover" })) : (_jsxs("div", { className: "flex items-center gap-2 text-sm text-discord-text-secondary py-4 px-2", children: [_jsx("svg", { className: "w-8 h-8 opacity-60", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" }) }), _jsx("span", { className: "truncate max-w-[120px] font-medium", children: file.name })] })), _jsx("button", { onClick: () => removeFile(i), className: "absolute -top-2 -right-2 w-7 h-7 bg-discord-red hover:bg-discord-red-hover shadow-elevation-high rounded-lg flex items-center justify-center text-white transition-colors z-10", children: _jsx("svg", { width: "14", height: "14", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" }) }) })] }, i))) })), _jsxs("div", { className: "flex items-start px-1", children: [_jsx("button", { onClick: () => fileInputRef.current?.click(), className: "p-3 text-discord-text-muted hover:text-discord-text-secondary transition-colors sticky top-0", title: "Attach file", children: _jsx("div", { className: "bg-discord-text-muted/20 hover:bg-discord-text-muted/40 rounded-full p-0.5 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 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" }) }) }) }), _jsx("input", { ref: fileInputRef, type: "file", multiple: true, className: "hidden", onChange: (e) => {
|
||||
const selected = Array.from(e.target.files ?? []);
|
||||
if (selected.length > 0) {
|
||||
setFiles((prev) => [...prev, ...selected]);
|
||||
}
|
||||
e.target.value = '';
|
||||
} }), _jsx("textarea", { ref: textareaRef, value: content, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, placeholder: `Message #${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-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" }) }) })] })] })] }));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
@@ -21,7 +22,12 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
|
||||
const handleTyping = useCallback(() => {
|
||||
if (typingTimeoutRef.current) return;
|
||||
wsSend({ type: 'typing_start', channelId });
|
||||
const isDm = useUIStore.getState().showDms;
|
||||
if (isDm) {
|
||||
wsSend({ type: 'dm_typing_start', dmChannelId: channelId });
|
||||
} else {
|
||||
wsSend({ type: 'typing_start', channelId });
|
||||
}
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
typingTimeoutRef.current = undefined;
|
||||
}, 3000);
|
||||
@@ -107,7 +113,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
return (
|
||||
<div className="px-4 pb-6 flex-shrink-0">
|
||||
{replyTo && (
|
||||
<div className="bg-[#2e3035] rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-discord-bg-tertiary/50">
|
||||
<div className="bg-discord-bg-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-discord-bg-tertiary/50">
|
||||
<div className="flex items-center gap-1 text-[14px] text-discord-text-normal truncate">
|
||||
<span className="opacity-60">Replying to</span>
|
||||
<span className="font-bold">{replyTo.user.displayName ?? replyTo.user.username}</span>
|
||||
@@ -131,7 +137,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
{files.length > 0 && (
|
||||
<div className="p-4 flex flex-wrap gap-4 bg-discord-bg-secondary/30">
|
||||
{files.map((file, i) => (
|
||||
<div key={i} className="relative group bg-discord-bg-secondary rounded-lg p-2 max-w-[200px] shadow-sm border border-discord-bg-tertiary">
|
||||
<div key={i} className="relative group bg-discord-bg-secondary rounded-lg p-2 max-w-[200px] shadow-elevation-low border border-discord-bg-tertiary">
|
||||
{file.type.startsWith('image/') ? (
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
@@ -148,7 +154,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeFile(i)}
|
||||
className="absolute -top-2 -right-2 w-7 h-7 bg-discord-red hover:bg-discord-red-hover shadow-lg rounded-lg flex items-center justify-center text-white transition-colors z-10"
|
||||
className="absolute -top-2 -right-2 w-7 h-7 bg-discord-red hover:bg-discord-red-hover shadow-elevation-high rounded-lg flex items-center justify-center text-white transition-colors z-10"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" />
|
||||
@@ -193,7 +199,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={`Message #${channelName}`}
|
||||
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}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CreateChannelModal } from '../modals/CreateChannel';
|
||||
import { InviteModal } from '../modals/InviteModal';
|
||||
import { UserSettingsModal } from '../modals/UserSettings';
|
||||
import { ServerSettingsModal } from '../modals/ServerSettings';
|
||||
import { NewDmModal } from '../modals/NewDmModal';
|
||||
import { UserProfilePopout } from '../ui/UserProfilePopout';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||
@@ -88,5 +89,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(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(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 })] }))] }));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CreateChannelModal } from '../modals/CreateChannel';
|
||||
import { InviteModal } from '../modals/InviteModal';
|
||||
import { UserSettingsModal } from '../modals/UserSettings';
|
||||
import { ServerSettingsModal } from '../modals/ServerSettings';
|
||||
import { NewDmModal } from '../modals/NewDmModal';
|
||||
import { UserProfilePopout } from '../ui/UserProfilePopout';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||
@@ -145,6 +146,7 @@ export function AppLayout() {
|
||||
<InviteModal />
|
||||
<UserSettingsModal />
|
||||
<ServerSettingsModal />
|
||||
<NewDmModal />
|
||||
<ImagePreview />
|
||||
|
||||
{/* User Profile Popout */}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { VoiceControls } from '../voice/VoiceControls';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
export function ChannelSidebar() {
|
||||
const servers = useServerStore((s) => s.servers);
|
||||
const currentServerId = useServerStore((s) => s.currentServerId);
|
||||
@@ -21,7 +22,25 @@ export function ChannelSidebar() {
|
||||
const members = useServerStore((s) => s.members);
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
const setCurrentVoiceChannel = useVoiceStore((s) => s.setCurrentVoiceChannel);
|
||||
const isMuted = useVoiceStore((s) => s.isMuted);
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const toggleMic = useVoiceStore((s) => s.toggleMic);
|
||||
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
||||
const navigate = useNavigate();
|
||||
const handleMicToggle = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (room) {
|
||||
try {
|
||||
await room.localParticipant.setMicrophoneEnabled(isMuted);
|
||||
} catch (err) {
|
||||
console.error('[ChannelSidebar] Failed to toggle mic:', err);
|
||||
}
|
||||
}
|
||||
toggleMic();
|
||||
};
|
||||
const handleDeafenToggle = () => {
|
||||
toggleDeafen();
|
||||
};
|
||||
const server = servers.find(s => s.id === currentServerId);
|
||||
const currentMember = members.find(m => m.userId === user?.id);
|
||||
const isAdminUser = currentMember?.role === 'admin' || currentMember?.role === 'owner';
|
||||
@@ -48,14 +67,14 @@ export function ChannelSidebar() {
|
||||
if (!server) {
|
||||
return (_jsxs("div", { className: "w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0 select-none", children: [_jsx("div", { className: "h-12 px-4 flex items-center shadow-header z-10", children: _jsx("button", { className: "flex-1 bg-discord-bg-tertiary text-discord-text-muted text-[14px] font-medium py-1 px-2 rounded-[4px] text-left hover:bg-discord-bg-tertiary/80 transition-colors", children: "Find or start a conversation" }) }), _jsxs("div", { className: "flex-1 overflow-y-auto pt-4 px-2 no-scrollbar", children: [_jsxs("div", { onClick: handleHomeClick, className: `flex items-center gap-3 px-2 h-10 rounded-[4px] cursor-pointer mb-0.5 transition-colors group ${!currentChannelId
|
||||
? 'bg-discord-modifier-selected text-white'
|
||||
: 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'}`, children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: `${!currentChannelId ? 'text-white' : 'opacity-70 group-hover:opacity-100'}`, children: _jsx("path", { d: "M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" }) }), _jsx("span", { className: "font-medium text-[16px]", children: "Friends" })] }), _jsxs("div", { className: "mt-[18px] px-2 mb-1 flex items-center justify-between group", children: [_jsx("span", { className: "text-[12px] font-bold text-discord-text-muted uppercase tracking-wider", children: "Direct Messages" }), _jsx("button", { className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) })] }), _jsxs("div", { className: "space-y-[2px]", children: [dmChannels.map((dm) => {
|
||||
: 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'}`, children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: `${!currentChannelId ? 'text-white' : 'opacity-70 group-hover:opacity-100'}`, children: _jsx("path", { d: "M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" }) }), _jsx("span", { className: "font-medium text-[16px]", children: "Friends" })] }), _jsxs("div", { className: "mt-[18px] px-2 mb-1 flex items-center justify-between group", children: [_jsx("span", { className: "text-[12px] font-bold text-discord-text-muted uppercase tracking-wider", children: "Direct Messages" }), _jsx("button", { onClick: () => openModal('newDm'), className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "New Direct Message", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) })] }), _jsxs("div", { className: "space-y-[2px]", children: [dmChannels.map((dm) => {
|
||||
const otherUser = dm.members.find(m => m.id !== user?.id);
|
||||
if (!otherUser)
|
||||
return null;
|
||||
return (_jsxs("div", { onClick: () => handleChannelClick(dm.id), className: `flex items-center gap-3 px-2 h-[42px] rounded-[4px] cursor-pointer transition-colors group ${currentChannelId === dm.id
|
||||
? 'bg-discord-modifier-selected text-white'
|
||||
: 'text-discord-text-muted hover:bg-discord-modifier-hover hover:text-discord-text-secondary'}`, children: [_jsx(Avatar, { src: otherUser.avatar, name: otherUser.displayName ?? otherUser.username, size: 32, status: otherUser.status }), _jsx("div", { className: "flex-1 min-w-0", children: _jsx("div", { className: `text-[16px] font-medium truncate ${currentChannelId === dm.id ? 'text-white' : 'text-discord-text-muted group-hover:text-discord-text-secondary'}`, children: otherUser.displayName ?? otherUser.username }) })] }, dm.id));
|
||||
}), dmChannels.length === 0 && (_jsx("p", { className: "px-2 py-4 text-[13px] text-discord-text-muted italic opacity-60", children: "No DM conversations yet." }))] })] }), user && (_jsxs("div", { className: "h-[52px] px-2 bg-[#232428] flex items-center gap-2 select-none", children: [_jsxs("div", { className: "p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status, user: user }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-[14px] font-bold text-discord-text-primary truncate leading-tight", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[12px] text-discord-text-muted truncate leading-tight group-hover:text-discord-text-secondary", children: ["@", user.username] })] })] }), _jsxs("div", { className: "flex items-center", children: [_jsx(UserAreaButton, { title: "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" })] }) }), _jsx(UserAreaButton, { title: "Deafen", children: _jsx("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" }) }) }), _jsx(UserAreaButton, { title: "User Settings", onClick: () => openModal('userSettings'), children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) })] })] }))] }));
|
||||
}), dmChannels.length === 0 && (_jsx("p", { className: "px-2 py-4 text-[13px] text-discord-text-muted italic opacity-60", children: "No DM conversations yet." }))] })] }), user && (_jsxs("div", { className: "h-[52px] px-2 bg-discord-bg-user-area flex items-center gap-2 select-none", children: [_jsxs("div", { className: "p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status, user: user }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-[14px] font-bold text-discord-text-primary truncate leading-tight", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[12px] text-discord-text-muted truncate leading-tight group-hover:text-discord-text-secondary", children: ["@", user.username] })] })] }), _jsxs("div", { className: "flex items-center", children: [_jsx(UserAreaButton, { title: isMuted ? 'Unmute' : 'Mute', onClick: handleMicToggle, active: isMuted, children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" }), _jsx("path", { d: "M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" }), isMuted && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2" })] }) }), _jsx(UserAreaButton, { title: isDeafened ? 'Undeafen' : 'Deafen', onClick: handleDeafenToggle, active: isDeafened, children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }), isDeafened && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2" })] }) }), _jsx(UserAreaButton, { title: "User Settings", onClick: () => openModal('userSettings'), children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) })] })] }))] }));
|
||||
}
|
||||
return (_jsxs("div", { className: "w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0 select-none", children: [_jsxs("button", { onClick: () => openModal('serverSettings'), className: "h-12 px-4 flex items-center justify-between shadow-header z-10 hover:bg-discord-modifier-hover transition-colors group", children: [_jsx("span", { className: "font-bold text-[16px] text-discord-text-primary truncate leading-tight", children: server.name }), _jsx("svg", { width: "18", height: "18", viewBox: "0 0 18 18", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0 group-hover:text-discord-text-secondary", children: _jsx("path", { d: "M5.293 7.293a1 1 0 011.414 0L9 9.586l2.293-2.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" }) })] }), _jsxs("div", { className: "flex-1 overflow-y-auto pt-3 px-2 space-y-[21px] no-scrollbar", children: [_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-1 mb-1 group cursor-pointer", children: [_jsxs("div", { className: "flex items-center gap-0.5 text-discord-text-muted hover:text-discord-text-secondary transition-colors", children: [_jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "currentColor", className: "opacity-70", children: _jsx("path", { d: "M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" }) }), _jsx("span", { className: "text-[12px] font-bold uppercase tracking-wider", children: "Text Channels" })] }), isAdminUser && (_jsx("button", { onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -65,8 +84,8 @@ export function ChannelSidebar() {
|
||||
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover'}`, children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "flex-shrink-0 opacity-60", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }), _jsx("span", { className: "truncate font-medium text-[16px]", children: channel.name })] }, channel.id))) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-1 mb-1 group cursor-pointer", children: [_jsxs("div", { className: "flex items-center gap-0.5 text-discord-text-muted hover:text-discord-text-secondary transition-colors", children: [_jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "currentColor", className: "opacity-70", children: _jsx("path", { d: "M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z" }) }), _jsx("span", { className: "text-[12px] font-bold uppercase tracking-wider", children: "Voice Channels" })] }), isAdminUser && (_jsx("button", { onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
openModal('createChannel');
|
||||
}, className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Create Channel", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) }))] }), _jsx("div", { className: "space-y-[2px]", children: voiceChannels.map((channel) => (_jsx(VoiceChannel, { channelId: channel.id, channelName: channel.name, onClick: () => handleVoiceJoin(channel.id) }, channel.id))) })] }), _jsx("div", { className: "pt-2", children: _jsxs("button", { onClick: () => openModal('invite'), className: "w-full flex items-center gap-2 px-2 h-8 rounded-[4px] text-[15px] font-medium text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover transition-colors", children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "opacity-60", children: _jsx("path", { d: "M13 13v5h-2v-5H6v-2h5V6h2v5h5v2h-5z" }) }), "Invite People"] }) })] }), currentVoiceChannelId && _jsx(VoiceControls, {}), user && (_jsxs("div", { className: "h-[52px] px-2 bg-[#232428] flex items-center gap-2 select-none", children: [_jsxs("div", { className: "p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status, user: user }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-[14px] font-bold text-discord-text-primary truncate leading-tight", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[12px] text-discord-text-muted truncate leading-tight group-hover:text-discord-text-secondary", children: ["@", user.username] })] })] }), _jsxs("div", { className: "flex items-center", children: [_jsx(UserAreaButton, { title: "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" })] }) }), _jsx(UserAreaButton, { title: "Deafen", children: _jsx("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" }) }) }), _jsx(UserAreaButton, { title: "User Settings", onClick: () => openModal('userSettings'), children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) })] })] }))] }));
|
||||
}, className: "text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Create Channel", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) }))] }), _jsx("div", { className: "space-y-[2px]", children: voiceChannels.map((channel) => (_jsx(VoiceChannel, { channelId: channel.id, channelName: channel.name, onClick: () => handleVoiceJoin(channel.id) }, channel.id))) })] }), _jsx("div", { className: "pt-2", children: _jsxs("button", { onClick: () => openModal('invite'), className: "w-full flex items-center gap-2 px-2 h-8 rounded-[4px] text-[15px] font-medium text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover transition-colors", children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "opacity-60", children: _jsx("path", { d: "M13 13v5h-2v-5H6v-2h5V6h2v5h5v2h-5z" }) }), "Invite People"] }) })] }), currentVoiceChannelId && _jsx(VoiceControls, {}), user && (_jsxs("div", { className: "h-[52px] px-2 bg-discord-bg-user-area flex items-center gap-2 select-none", children: [_jsxs("div", { className: "p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status, user: user }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-[14px] font-bold text-discord-text-primary truncate leading-tight", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[12px] text-discord-text-muted truncate leading-tight group-hover:text-discord-text-secondary", children: ["@", user.username] })] })] }), _jsxs("div", { className: "flex items-center", children: [_jsx(UserAreaButton, { title: isMuted ? 'Unmute' : 'Mute', onClick: handleMicToggle, active: isMuted, children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" }), _jsx("path", { d: "M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" }), isMuted && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2" })] }) }), _jsx(UserAreaButton, { title: isDeafened ? 'Undeafen' : 'Deafen', onClick: handleDeafenToggle, active: isDeafened, children: _jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }), isDeafened && _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2" })] }) }), _jsx(UserAreaButton, { title: "User Settings", onClick: () => openModal('userSettings'), children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) })] })] }))] }));
|
||||
}
|
||||
function UserAreaButton({ children, title, onClick }) {
|
||||
return (_jsx("button", { onClick: onClick, 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-all", title: title, children: children }));
|
||||
function UserAreaButton({ children, title, onClick, active }) {
|
||||
return (_jsx("button", { onClick: onClick, className: `w-8 h-8 flex items-center justify-center hover:bg-discord-modifier-hover rounded-[4px] transition-all ${active ? 'text-discord-red' : 'text-discord-text-muted hover:text-discord-text-primary'}`, title: title, children: children }));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
@@ -9,6 +9,7 @@ import { VoiceControls } from '../voice/VoiceControls';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
|
||||
export function ChannelSidebar() {
|
||||
const servers = useServerStore((s) => s.servers);
|
||||
@@ -22,8 +23,28 @@ export function ChannelSidebar() {
|
||||
const members = useServerStore((s) => s.members);
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
const setCurrentVoiceChannel = useVoiceStore((s) => s.setCurrentVoiceChannel);
|
||||
const isMuted = useVoiceStore((s) => s.isMuted);
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const toggleMic = useVoiceStore((s) => s.toggleMic);
|
||||
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleMicToggle = async () => {
|
||||
const room = getActiveRoom();
|
||||
if (room) {
|
||||
try {
|
||||
await room.localParticipant.setMicrophoneEnabled(isMuted);
|
||||
} catch (err) {
|
||||
console.error('[ChannelSidebar] Failed to toggle mic:', err);
|
||||
}
|
||||
}
|
||||
toggleMic();
|
||||
};
|
||||
|
||||
const handleDeafenToggle = () => {
|
||||
toggleDeafen();
|
||||
};
|
||||
|
||||
const server = servers.find(s => s.id === currentServerId);
|
||||
const currentMember = members.find(m => m.userId === user?.id);
|
||||
const isAdminUser = currentMember?.role === 'admin' || currentMember?.role === 'owner';
|
||||
@@ -77,7 +98,11 @@ export function ChannelSidebar() {
|
||||
|
||||
<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 className="text-discord-text-muted hover:text-discord-text-primary transition-colors">
|
||||
<button
|
||||
onClick={() => openModal('newDm')}
|
||||
className="text-discord-text-muted hover:text-discord-text-primary transition-colors"
|
||||
title="New Direct Message"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
|
||||
</svg>
|
||||
@@ -116,33 +141,14 @@ export function ChannelSidebar() {
|
||||
|
||||
{/* User area at bottom */}
|
||||
{user && (
|
||||
<div className="h-[52px] px-2 bg-[#232428] flex items-center gap-2 select-none">
|
||||
<div className="p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group">
|
||||
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status as any} user={user} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[14px] font-bold text-discord-text-primary truncate leading-tight">{user.displayName ?? user.username}</div>
|
||||
<div className="text-[12px] text-discord-text-muted truncate leading-tight group-hover:text-discord-text-secondary">@{user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<UserAreaButton title="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" />
|
||||
</svg>
|
||||
</UserAreaButton>
|
||||
<UserAreaButton title="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" />
|
||||
</svg>
|
||||
</UserAreaButton>
|
||||
<UserAreaButton title="User Settings" onClick={() => openModal('userSettings')}>
|
||||
<svg width="20" height="20" 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>
|
||||
</UserAreaButton>
|
||||
</div>
|
||||
</div>
|
||||
<UserAreaPanel
|
||||
user={user}
|
||||
isMuted={isMuted}
|
||||
isDeafened={isDeafened}
|
||||
onMicToggle={handleMicToggle}
|
||||
onDeafenToggle={handleDeafenToggle}
|
||||
onSettingsClick={() => openModal('userSettings')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -262,47 +268,407 @@ export function ChannelSidebar() {
|
||||
|
||||
{/* User area */}
|
||||
{user && (
|
||||
<div className="h-[52px] px-2 bg-[#232428] flex items-center gap-2 select-none">
|
||||
<div className="p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group">
|
||||
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status as any} user={user} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[14px] font-bold text-discord-text-primary truncate leading-tight">{user.displayName ?? user.username}</div>
|
||||
<div className="text-[12px] text-discord-text-muted truncate leading-tight group-hover:text-discord-text-secondary">@{user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<UserAreaButton title="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" />
|
||||
</svg>
|
||||
</UserAreaButton>
|
||||
<UserAreaButton title="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" />
|
||||
</svg>
|
||||
</UserAreaButton>
|
||||
<UserAreaButton title="User Settings" onClick={() => openModal('userSettings')}>
|
||||
<svg width="20" height="20" 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>
|
||||
</UserAreaButton>
|
||||
</div>
|
||||
</div>
|
||||
<UserAreaPanel
|
||||
user={user}
|
||||
isMuted={isMuted}
|
||||
isDeafened={isDeafened}
|
||||
onMicToggle={handleMicToggle}
|
||||
onDeafenToggle={handleDeafenToggle}
|
||||
onSettingsClick={() => openModal('userSettings')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserAreaButton({ children, title, onClick }: { children: React.ReactNode, title: string, onClick?: () => void }) {
|
||||
/* ─── User Area Panel ──────────────────────────────────────────────────────── */
|
||||
|
||||
function UserAreaPanel({
|
||||
user,
|
||||
isMuted,
|
||||
isDeafened,
|
||||
onMicToggle,
|
||||
onDeafenToggle,
|
||||
onSettingsClick,
|
||||
}: {
|
||||
user: any;
|
||||
isMuted: boolean;
|
||||
isDeafened: boolean;
|
||||
onMicToggle: () => void;
|
||||
onDeafenToggle: () => void;
|
||||
onSettingsClick: () => void;
|
||||
}) {
|
||||
const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null);
|
||||
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [outputDevices, setOutputDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [selectedInput, setSelectedInput] = useState<string>('default');
|
||||
const [selectedOutput, setSelectedOutput] = useState<string>('default');
|
||||
const [selectedInputLabel, setSelectedInputLabel] = useState<string>('Default');
|
||||
const [selectedOutputLabel, setSelectedOutputLabel] = useState<string>('Default');
|
||||
const inputVolume = useVoiceStore((s) => s.inputVolume);
|
||||
const storeSetInputVolume = useVoiceStore((s) => s.setInputVolume);
|
||||
const outputVolume = useVoiceStore((s) => s.outputVolume);
|
||||
const storeSetOutputVolume = useVoiceStore((s) => s.setOutputVolume);
|
||||
const [showInputDeviceList, setShowInputDeviceList] = useState(false);
|
||||
const [showOutputDeviceList, setShowOutputDeviceList] = useState(false);
|
||||
const [micLevel, setMicLevel] = useState(0);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
try {
|
||||
// Need to request permission first to get labels
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop()));
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
setInputDevices(devices.filter(d => d.kind === 'audioinput'));
|
||||
setOutputDevices(devices.filter(d => d.kind === 'audiooutput'));
|
||||
} catch {
|
||||
// permission denied
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Start mic level monitoring when input panel opens
|
||||
useEffect(() => {
|
||||
if (openPanel !== 'input') {
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
analyserRef.current = null;
|
||||
setMicLevel(0);
|
||||
return;
|
||||
}
|
||||
let stream: MediaStream | null = null;
|
||||
let ctx: AudioContext | null = null;
|
||||
const start = async () => {
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: selectedInput !== 'default' ? selectedInput : undefined } });
|
||||
ctx = new AudioContext();
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
analyserRef.current = analyser;
|
||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||
const tick = () => {
|
||||
if (!analyserRef.current) return;
|
||||
analyserRef.current.getByteFrequencyData(data);
|
||||
const avg = data.reduce((a, b) => a + b, 0) / data.length;
|
||||
setMicLevel(Math.min(avg / 128, 1));
|
||||
animFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
} catch { /* no mic access */ }
|
||||
};
|
||||
start();
|
||||
return () => {
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
analyserRef.current = null;
|
||||
stream?.getTracks().forEach(t => t.stop());
|
||||
ctx?.close();
|
||||
};
|
||||
}, [openPanel, selectedInput]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||
setOpenPanel(null);
|
||||
setShowInputDeviceList(false);
|
||||
setShowOutputDeviceList(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
const togglePanel = (panel: 'input' | 'output') => {
|
||||
if (openPanel === panel) {
|
||||
setOpenPanel(null);
|
||||
} else {
|
||||
loadDevices();
|
||||
setOpenPanel(panel);
|
||||
setShowInputDeviceList(false);
|
||||
setShowOutputDeviceList(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectInput = (device: MediaDeviceInfo) => {
|
||||
setSelectedInput(device.deviceId);
|
||||
setSelectedInputLabel(device.label || 'Default');
|
||||
setShowInputDeviceList(false);
|
||||
const room = getActiveRoom();
|
||||
if (room) room.switchActiveDevice('audioinput', device.deviceId).catch(() => {});
|
||||
};
|
||||
|
||||
const selectOutput = (device: MediaDeviceInfo) => {
|
||||
setSelectedOutput(device.deviceId);
|
||||
setSelectedOutputLabel(device.label || 'Default');
|
||||
setShowOutputDeviceList(false);
|
||||
const room = getActiveRoom();
|
||||
if (room) room.switchActiveDevice('audiooutput', device.deviceId).catch(() => {});
|
||||
};
|
||||
|
||||
// Generate mic level bars (20 bars like Discord)
|
||||
const micBars = 20;
|
||||
const activeBars = Math.round(micLevel * micBars * (inputVolume / 100));
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
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-all"
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
<div className="relative" ref={panelRef}>
|
||||
{/* Input settings panel */}
|
||||
{openPanel === 'input' && (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-0 bg-[#2b2d31] rounded-t-lg shadow-lg z-50 border-t border-x border-discord-bg-tertiary">
|
||||
{/* Input Device */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowInputDeviceList(!showInputDeviceList)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[15px] font-semibold text-discord-text-primary text-left">Input Device</div>
|
||||
<div className="text-[13px] text-discord-text-muted truncate text-left">{selectedInputLabel}</div>
|
||||
</div>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted flex-shrink-0 ml-2">
|
||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
|
||||
</svg>
|
||||
</button>
|
||||
{showInputDeviceList && (
|
||||
<div className="bg-discord-bg-floating rounded-lg shadow-lg mx-2 mb-2 py-1 border border-discord-bg-tertiary">
|
||||
{inputDevices.map(d => (
|
||||
<button
|
||||
key={d.deviceId}
|
||||
onClick={() => selectInput(d)}
|
||||
className={`w-full px-3 py-2 text-left text-[13px] hover:bg-discord-modifier-hover transition-colors flex items-center gap-2 ${
|
||||
selectedInput === d.deviceId ? 'text-discord-text-primary' : 'text-discord-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{selectedInput === d.deviceId && (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-discord-blurple flex-shrink-0">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className={selectedInput === d.deviceId ? '' : 'pl-6'}>{d.label || 'Default'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-4 border-t border-[#3f4147]" />
|
||||
|
||||
{/* Input Volume */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="text-[15px] font-semibold text-discord-text-primary mb-2">Input Volume</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={200}
|
||||
value={inputVolume}
|
||||
onChange={(e) => {
|
||||
const vol = Number(e.target.value);
|
||||
storeSetInputVolume(vol);
|
||||
// Apply gain to mic: at 0 = mute, 100 = normal, 200 = 2x boost
|
||||
const room = getActiveRoom();
|
||||
if (room && room.localParticipant.isMicrophoneEnabled) {
|
||||
if (vol === 0) {
|
||||
room.localParticipant.setMicrophoneEnabled(false).catch(() => {});
|
||||
} else {
|
||||
// Re-enable mic if it was muted by volume slider
|
||||
room.localParticipant.setMicrophoneEnabled(true).catch(() => {});
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-full h-1.5 rounded-full appearance-none cursor-pointer accent-discord-blurple bg-discord-bg-tertiary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #5865f2 0%, #5865f2 ${inputVolume / 2}%, #4e5058 ${inputVolume / 2}%, #4e5058 100%)`,
|
||||
}}
|
||||
/>
|
||||
{/* Mic level meter */}
|
||||
<div className="flex items-center gap-[3px] mt-2.5">
|
||||
{Array.from({ length: micBars }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 h-[6px] rounded-[1px] transition-colors duration-75 ${
|
||||
i < activeBars ? 'bg-discord-text-muted' : 'bg-[#3f4147]'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-4 border-t border-[#3f4147]" />
|
||||
|
||||
{/* Voice Settings link */}
|
||||
<button
|
||||
onClick={onSettingsClick}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors"
|
||||
>
|
||||
<span className="text-[15px] font-semibold text-discord-text-primary">Voice Settings</span>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output settings panel */}
|
||||
{openPanel === 'output' && (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-0 bg-[#2b2d31] rounded-t-lg shadow-lg z-50 border-t border-x border-discord-bg-tertiary">
|
||||
{/* Output Device */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowOutputDeviceList(!showOutputDeviceList)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[15px] font-semibold text-discord-text-primary text-left">Output Device</div>
|
||||
<div className="text-[13px] text-discord-text-muted truncate text-left">{selectedOutputLabel}</div>
|
||||
</div>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted flex-shrink-0 ml-2">
|
||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
|
||||
</svg>
|
||||
</button>
|
||||
{showOutputDeviceList && (
|
||||
<div className="bg-discord-bg-floating rounded-lg shadow-lg mx-2 mb-2 py-1 border border-discord-bg-tertiary">
|
||||
{outputDevices.map(d => (
|
||||
<button
|
||||
key={d.deviceId}
|
||||
onClick={() => selectOutput(d)}
|
||||
className={`w-full px-3 py-2 text-left text-[13px] hover:bg-discord-modifier-hover transition-colors flex items-center gap-2 ${
|
||||
selectedOutput === d.deviceId ? 'text-discord-text-primary' : 'text-discord-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{selectedOutput === d.deviceId && (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-discord-blurple flex-shrink-0">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className={selectedOutput === d.deviceId ? '' : 'pl-6'}>{d.label || 'Default'}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-4 border-t border-[#3f4147]" />
|
||||
|
||||
{/* Output Volume */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="text-[15px] font-semibold text-discord-text-primary mb-2">Output Volume</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={200}
|
||||
value={outputVolume}
|
||||
onChange={(e) => {
|
||||
const vol = Number(e.target.value);
|
||||
storeSetOutputVolume(vol);
|
||||
// Apply volume to all remote participants
|
||||
const room = getActiveRoom();
|
||||
if (room) {
|
||||
const scaled = vol / 100; // 0-2 range (0%=0, 100%=1, 200%=2)
|
||||
room.remoteParticipants.forEach((participant) => {
|
||||
participant.setVolume(scaled);
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="w-full h-1.5 rounded-full appearance-none cursor-pointer bg-discord-bg-tertiary [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #5865f2 0%, #5865f2 ${outputVolume / 2}%, #4e5058 ${outputVolume / 2}%, #4e5058 100%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mx-4 border-t border-[#3f4147]" />
|
||||
|
||||
{/* Voice Settings link */}
|
||||
<button
|
||||
onClick={onSettingsClick}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-discord-modifier-hover transition-colors"
|
||||
>
|
||||
<span className="text-[15px] font-semibold text-discord-text-primary">Voice Settings</span>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User area bar */}
|
||||
<div className="h-[52px] px-2 bg-discord-bg-user-area flex items-center select-none">
|
||||
{/* Avatar + name */}
|
||||
<div className="p-1 hover:bg-discord-modifier-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group">
|
||||
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status as any} user={user} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-semibold text-discord-text-primary truncate leading-tight">{user.displayName ?? user.username}</div>
|
||||
<div className="text-[11px] text-discord-text-muted truncate leading-tight group-hover:text-discord-text-secondary">@{user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center">
|
||||
{/* Mic */}
|
||||
<button
|
||||
onClick={onMicToggle}
|
||||
className={`w-8 h-8 flex items-center justify-center hover:bg-discord-modifier-hover rounded-l-[4px] transition-colors ${
|
||||
isMuted ? 'text-discord-red' : 'text-discord-text-muted hover:text-discord-text-primary'
|
||||
}`}
|
||||
title={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
<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 && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
|
||||
</svg>
|
||||
</button>
|
||||
{/* Input chevron */}
|
||||
<button
|
||||
onClick={() => togglePanel('input')}
|
||||
className={`w-[18px] h-8 flex items-center justify-center hover:bg-discord-modifier-hover rounded-r-[4px] transition-colors ${
|
||||
openPanel === 'input' ? 'text-discord-text-primary bg-discord-modifier-hover' : 'text-discord-text-muted hover:text-discord-text-primary'
|
||||
}`}
|
||||
title="Input Devices"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" className={`transition-transform ${openPanel === 'input' ? 'rotate-180' : ''}`}>
|
||||
<path d="M7 10l5 5 5-5z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Headphones */}
|
||||
<button
|
||||
onClick={onDeafenToggle}
|
||||
className={`w-8 h-8 flex items-center justify-center hover:bg-discord-modifier-hover rounded-l-[4px] transition-colors ${
|
||||
isDeafened ? 'text-discord-red' : 'text-discord-text-muted hover:text-discord-text-primary'
|
||||
}`}
|
||||
title={isDeafened ? 'Undeafen' : 'Deafen'}
|
||||
>
|
||||
<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>
|
||||
{/* Output chevron */}
|
||||
<button
|
||||
onClick={() => togglePanel('output')}
|
||||
className={`w-[18px] h-8 flex items-center justify-center hover:bg-discord-modifier-hover rounded-r-[4px] transition-colors ${
|
||||
openPanel === 'output' ? 'text-discord-text-primary bg-discord-modifier-hover' : 'text-discord-text-muted hover:text-discord-text-primary'
|
||||
}`}
|
||||
title="Output Devices"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" className={`transition-transform ${openPanel === 'output' ? 'rotate-180' : ''}`}>
|
||||
<path d="M7 10l5 5 5-5z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Settings */}
|
||||
<button
|
||||
onClick={onSettingsClick}
|
||||
className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary hover:bg-discord-modifier-hover rounded-[4px] transition-colors"
|
||||
title="User Settings"
|
||||
>
|
||||
<svg width="20" height="20" 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { MessageList } from '../chat/MessageList';
|
||||
import { MessageInput } from '../chat/MessageInput';
|
||||
import { TypingIndicator } from '../chat/TypingIndicator';
|
||||
import { VoiceGrid } from '../voice/VoiceGrid';
|
||||
import { FriendsPage } from '../chat/FriendsPage';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
export function MainContent() {
|
||||
@@ -18,6 +20,8 @@ export function MainContent() {
|
||||
const participants = useVoiceStore((s) => s.participants);
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
const showDms = useUIStore((s) => s.showDms);
|
||||
const dmChannels = useServerStore((s) => s.dmChannels);
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
const channel = channels.find(c => c.id === currentChannelId);
|
||||
const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video';
|
||||
// DM view or no server selected
|
||||
@@ -25,7 +29,11 @@ export function MainContent() {
|
||||
if (!currentChannelId) {
|
||||
return _jsx(FriendsPage, {});
|
||||
}
|
||||
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative", children: [_jsx("div", { className: "h-12 px-4 flex items-center shadow-header flex-shrink-0 z-10", children: _jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("span", { className: "text-discord-text-muted font-bold text-lg", children: "@" }), _jsx("span", { className: "font-bold text-discord-text-primary truncate", children: "Direct Message" })] }) }), _jsx(MessageList, { channelId: currentChannelId }), _jsx(TypingIndicator, { channelId: currentChannelId }), _jsx(MessageInput, { channelId: currentChannelId, channelName: "Direct Message" })] }));
|
||||
const dmChannel = dmChannels.find(dm => dm.id === currentChannelId);
|
||||
const otherUser = dmChannel?.members.find(m => m.id !== authUser?.id);
|
||||
const dmName = otherUser?.displayName ?? otherUser?.username ?? 'Direct Message';
|
||||
const dmStatus = otherUser?.status;
|
||||
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary min-w-0 relative", children: [_jsx("div", { className: "h-12 px-4 flex items-center shadow-header flex-shrink-0 z-10", children: _jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("span", { className: "text-discord-text-muted font-bold text-lg", children: "@" }), otherUser && _jsx(Avatar, { src: otherUser.avatar, name: dmName, size: 24, status: dmStatus }), _jsx("span", { className: "font-bold text-discord-text-primary truncate", children: dmName }), otherUser?.status && otherUser.status !== 'offline' && _jsx("span", { className: "text-xs text-discord-text-muted capitalize", children: otherUser.status })] }) }), _jsx(MessageList, { channelId: currentChannelId }), _jsx(TypingIndicator, { channelId: currentChannelId }), _jsx(MessageInput, { channelId: currentChannelId, channelName: `@${dmName}` })] }));
|
||||
}
|
||||
// No channel selected
|
||||
if (!currentChannelId || !channel) {
|
||||
|
||||
@@ -2,11 +2,13 @@ import React from 'react';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { MessageList } from '../chat/MessageList';
|
||||
import { MessageInput } from '../chat/MessageInput';
|
||||
import { TypingIndicator } from '../chat/TypingIndicator';
|
||||
import { VoiceGrid } from '../voice/VoiceGrid';
|
||||
import { FriendsPage } from '../chat/FriendsPage';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
|
||||
@@ -24,22 +26,36 @@ export function MainContent() {
|
||||
const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video';
|
||||
|
||||
// DM view or no server selected
|
||||
const dmChannels = useServerStore((s) => s.dmChannels);
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
|
||||
if (showDms || !currentServerId) {
|
||||
if (!currentChannelId) {
|
||||
return <FriendsPage />;
|
||||
}
|
||||
|
||||
|
||||
const dmChannel = dmChannels.find(dm => dm.id === currentChannelId);
|
||||
const otherUser = dmChannel?.members.find(m => m.id !== authUser?.id);
|
||||
const dmName = otherUser?.displayName ?? otherUser?.username ?? 'Direct Message';
|
||||
const dmStatus = otherUser?.status as any;
|
||||
|
||||
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">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-discord-text-muted font-bold text-lg">@</span>
|
||||
<span className="font-bold text-discord-text-primary truncate">Direct Message</span>
|
||||
{otherUser && (
|
||||
<Avatar src={otherUser.avatar} name={dmName} size={24} status={dmStatus} />
|
||||
)}
|
||||
<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>
|
||||
<MessageList channelId={currentChannelId} />
|
||||
<TypingIndicator channelId={currentChannelId} />
|
||||
<MessageInput channelId={currentChannelId} channelName="Direct Message" />
|
||||
<MessageInput channelId={currentChannelId} channelName={`@${dmName}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { api } from '../../api/client';
|
||||
export function NewDmModal() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const activeModal = useUIStore((s) => s.activeModal);
|
||||
const closeModal = useUIStore((s) => s.closeModal);
|
||||
const addDmChannel = useServerStore((s) => s.addDmChannel);
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef(null);
|
||||
const searchTimer = useRef();
|
||||
const isOpen = activeModal === 'newDm';
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setError('');
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
const handleSearch = (value) => {
|
||||
setQuery(value);
|
||||
setError('');
|
||||
if (searchTimer.current) {
|
||||
clearTimeout(searchTimer.current);
|
||||
}
|
||||
if (value.trim().length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
searchTimer.current = setTimeout(async () => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const users = await api.social.search(value.trim());
|
||||
setResults(users);
|
||||
}
|
||||
catch {
|
||||
setResults([]);
|
||||
}
|
||||
finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
const handleSelectUser = async (user) => {
|
||||
setError('');
|
||||
try {
|
||||
const channel = await api.dm.create({ userId: user.id });
|
||||
addDmChannel(channel);
|
||||
closeModal();
|
||||
useUIStore.getState().setShowDms(true);
|
||||
navigate(`/channels/@me/${channel.id}`);
|
||||
}
|
||||
catch (err) {
|
||||
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)))] })] }) }));
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { api } from '../../api/client';
|
||||
import type { User } from '@opencord/shared';
|
||||
|
||||
export function NewDmModal() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<User[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const activeModal = useUIStore((s) => s.activeModal);
|
||||
const closeModal = useUIStore((s) => s.closeModal);
|
||||
const addDmChannel = useServerStore((s) => s.addDmChannel);
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const isOpen = activeModal === 'newDm';
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setError('');
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setQuery(value);
|
||||
setError('');
|
||||
|
||||
if (searchTimer.current) {
|
||||
clearTimeout(searchTimer.current);
|
||||
}
|
||||
|
||||
if (value.trim().length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimer.current = setTimeout(async () => {
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const users = await api.social.search(value.trim());
|
||||
setResults(users);
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleSelectUser = async (user: User) => {
|
||||
setError('');
|
||||
try {
|
||||
const channel = await api.dm.create({ userId: user.id });
|
||||
addDmChannel(channel);
|
||||
closeModal();
|
||||
useUIStore.getState().setShowDms(true);
|
||||
navigate(`/channels/@me/${channel.id}`);
|
||||
} catch (err) {
|
||||
setError((err as Error).message || 'Failed to create DM');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={closeModal} title="New Direct Message">
|
||||
<div className="space-y-3">
|
||||
<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 && (
|
||||
<p className="text-discord-red text-[13px]">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="max-h-[300px] overflow-y-auto space-y-[2px]">
|
||||
{isSearching && (
|
||||
<div className="py-4 text-center text-discord-text-muted text-[14px]">Searching...</div>
|
||||
)}
|
||||
|
||||
{!isSearching && query.trim().length >= 2 && results.length === 0 && (
|
||||
<div className="py-4 text-center text-discord-text-muted text-[14px]">No users found</div>
|
||||
)}
|
||||
|
||||
{results.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
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"
|
||||
>
|
||||
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={36} status={user.status as any} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[14px] font-medium text-discord-text-primary truncate">
|
||||
{user.displayName ?? user.username}
|
||||
</div>
|
||||
<div className="text-[12px] text-discord-text-muted truncate">@{user.username}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { User } from '@opencord/shared';
|
||||
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';
|
||||
|
||||
interface UserProfilePopoutProps {
|
||||
user: User;
|
||||
@@ -15,14 +15,13 @@ interface UserProfilePopoutProps {
|
||||
export function UserProfilePopout({ user, onClose, position }: UserProfilePopoutProps) {
|
||||
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}`);
|
||||
} catch (err) {
|
||||
|
||||
@@ -13,7 +13,7 @@ function handleEvent(event) {
|
||||
const { setUser } = useAuthStore.getState();
|
||||
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember } = useServerStore.getState();
|
||||
const { addMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
|
||||
const { addVoiceUser, removeVoiceUser } = useVoiceStore.getState();
|
||||
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, setVoiceUsers } = useVoiceStore.getState();
|
||||
switch (event.type) {
|
||||
case 'ready':
|
||||
setUser(event.user);
|
||||
@@ -21,11 +21,10 @@ function handleEvent(event) {
|
||||
if (currentServerId) {
|
||||
loadServerDetail(currentServerId);
|
||||
}
|
||||
// Populate voice channel state so users see who's in voice on load
|
||||
// Clear stale voice state, then populate from server truth
|
||||
clearAllVoiceUsers();
|
||||
if (event.voiceStates) {
|
||||
const vs = event.voiceStates;
|
||||
const { setVoiceUsers } = useVoiceStore.getState();
|
||||
for (const [channelId, userIds] of Object.entries(vs)) {
|
||||
for (const [channelId, userIds] of Object.entries(event.voiceStates)) {
|
||||
setVoiceUsers(channelId, userIds);
|
||||
}
|
||||
}
|
||||
@@ -59,8 +58,31 @@ function handleEvent(event) {
|
||||
case 'member_left':
|
||||
removeMember(event.userId);
|
||||
break;
|
||||
case 'dm_message_created':
|
||||
case 'dm_message_created': {
|
||||
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
|
||||
);
|
||||
updatedDms.sort((a, b) => {
|
||||
const aTime = a.lastMessage?.createdAt ?? a.createdAt;
|
||||
const bTime = b.lastMessage?.createdAt ?? b.createdAt;
|
||||
return bTime - aTime;
|
||||
});
|
||||
setDmChannels(updatedDms);
|
||||
break;
|
||||
}
|
||||
case 'dm_message_updated':
|
||||
updateMessage(event.message);
|
||||
break;
|
||||
case 'dm_message_deleted':
|
||||
removeMessage(event.messageId, event.dmChannelId);
|
||||
break;
|
||||
case 'dm_typing':
|
||||
setTyping(event.dmChannelId, event.userId, event.username);
|
||||
break;
|
||||
case 'reaction_added':
|
||||
onReactionAdded(event.messageId, event.reaction);
|
||||
|
||||
@@ -16,7 +16,7 @@ function handleEvent(event: ServerEvent): void {
|
||||
const { setUser } = useAuthStore.getState();
|
||||
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember } = useServerStore.getState();
|
||||
const { addMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
|
||||
const { addVoiceUser, removeVoiceUser } = useVoiceStore.getState();
|
||||
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, setVoiceUsers } = useVoiceStore.getState();
|
||||
|
||||
switch (event.type) {
|
||||
case 'ready':
|
||||
@@ -25,11 +25,10 @@ function handleEvent(event: ServerEvent): void {
|
||||
if (currentServerId) {
|
||||
loadServerDetail(currentServerId);
|
||||
}
|
||||
// Populate voice channel state so users see who's in voice on load
|
||||
if ((event as any).voiceStates) {
|
||||
const vs = (event as any).voiceStates as Record<string, string[]>;
|
||||
const { setVoiceUsers } = useVoiceStore.getState();
|
||||
for (const [channelId, userIds] of Object.entries(vs)) {
|
||||
// Clear stale voice state, then populate from server truth
|
||||
clearAllVoiceUsers();
|
||||
if (event.voiceStates) {
|
||||
for (const [channelId, userIds] of Object.entries(event.voiceStates)) {
|
||||
setVoiceUsers(channelId, userIds);
|
||||
}
|
||||
}
|
||||
@@ -71,8 +70,35 @@ function handleEvent(event: ServerEvent): void {
|
||||
removeMember(event.userId);
|
||||
break;
|
||||
|
||||
case 'dm_message_created':
|
||||
case 'dm_message_created': {
|
||||
addMessage(event.message.dmChannelId, event.message as any);
|
||||
// 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
|
||||
);
|
||||
// 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);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'dm_message_updated':
|
||||
updateMessage(event.message as any);
|
||||
break;
|
||||
|
||||
case 'dm_message_deleted':
|
||||
removeMessage(event.messageId, event.dmChannelId);
|
||||
break;
|
||||
|
||||
case 'dm_typing':
|
||||
setTyping(event.dmChannelId, event.userId, event.username);
|
||||
break;
|
||||
|
||||
case 'reaction_added':
|
||||
|
||||
@@ -74,11 +74,21 @@ export const useChatStore = create((set, get) => ({
|
||||
// Message will arrive via WebSocket
|
||||
},
|
||||
editMessage: async (messageId, content) => {
|
||||
await api.messages.update(messageId, { content });
|
||||
const isDm = useUIStore.getState().showDms;
|
||||
if (isDm) {
|
||||
await api.dm.updateMessage(messageId, { content });
|
||||
} else {
|
||||
await api.messages.update(messageId, { content });
|
||||
}
|
||||
// Update will arrive via WebSocket
|
||||
},
|
||||
deleteMessage: async (messageId) => {
|
||||
await api.messages.delete(messageId);
|
||||
const isDm = useUIStore.getState().showDms;
|
||||
if (isDm) {
|
||||
await api.dm.deleteMessage(messageId);
|
||||
} else {
|
||||
await api.messages.delete(messageId);
|
||||
}
|
||||
// Deletion will arrive via WebSocket
|
||||
},
|
||||
addMessage: (channelId, message) => {
|
||||
@@ -93,12 +103,14 @@ export const useChatStore = create((set, get) => ({
|
||||
});
|
||||
},
|
||||
updateMessage: (message) => {
|
||||
const channelKey = message.channelId || message.dmChannelId;
|
||||
if (!channelKey) return;
|
||||
set((state) => {
|
||||
const newMessages = new Map(state.messages);
|
||||
const current = newMessages.get(message.channelId);
|
||||
const current = newMessages.get(channelKey);
|
||||
if (!current)
|
||||
return state;
|
||||
newMessages.set(message.channelId, current.map(m => m.id === message.id ? message : m));
|
||||
newMessages.set(channelKey, current.map(m => m.id === message.id ? message : m));
|
||||
return { messages: newMessages };
|
||||
});
|
||||
},
|
||||
|
||||
@@ -114,12 +114,22 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
},
|
||||
|
||||
editMessage: async (messageId: string, content: string) => {
|
||||
await api.messages.update(messageId, { content });
|
||||
const isDm = useUIStore.getState().showDms;
|
||||
if (isDm) {
|
||||
await api.dm.updateMessage(messageId, { content });
|
||||
} else {
|
||||
await api.messages.update(messageId, { content });
|
||||
}
|
||||
// Update will arrive via WebSocket
|
||||
},
|
||||
|
||||
deleteMessage: async (messageId: string) => {
|
||||
await api.messages.delete(messageId);
|
||||
const isDm = useUIStore.getState().showDms;
|
||||
if (isDm) {
|
||||
await api.dm.deleteMessage(messageId);
|
||||
} else {
|
||||
await api.messages.delete(messageId);
|
||||
}
|
||||
// Deletion will arrive via WebSocket
|
||||
},
|
||||
|
||||
@@ -135,12 +145,15 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
},
|
||||
|
||||
updateMessage: (message: MessageWithUser) => {
|
||||
// DM messages have dmChannelId instead of channelId — check both
|
||||
const channelKey = message.channelId || (message as any).dmChannelId;
|
||||
if (!channelKey) return;
|
||||
set((state) => {
|
||||
const newMessages = new Map(state.messages);
|
||||
const current = newMessages.get(message.channelId);
|
||||
const current = newMessages.get(channelKey);
|
||||
if (!current) return state;
|
||||
newMessages.set(
|
||||
message.channelId,
|
||||
channelKey,
|
||||
current.map(m => m.id === message.id ? message : m),
|
||||
);
|
||||
return { messages: newMessages };
|
||||
|
||||
@@ -9,6 +9,7 @@ type ModalType =
|
||||
| 'userSettings'
|
||||
| 'serverSettings'
|
||||
| 'imagePreview'
|
||||
| 'newDm'
|
||||
| null;
|
||||
|
||||
interface UIState {
|
||||
|
||||
@@ -43,6 +43,18 @@ export const useVoiceStore = create((set, get) => ({
|
||||
toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })),
|
||||
toggleScreenShare: () => set((state) => ({ isScreenSharing: !state.isScreenSharing })),
|
||||
getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [],
|
||||
clearAllVoiceUsers: () => set({ voiceUsers: new Map() }),
|
||||
// Leave voice without wiping the voiceUsers map (so sidebar still shows others)
|
||||
leaveVoice: () => set({
|
||||
currentVoiceChannelId: null,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isCameraOn: false,
|
||||
isScreenSharing: false,
|
||||
participants: [],
|
||||
connectionError: null,
|
||||
isLiveKitConnected: false,
|
||||
}),
|
||||
reset: () => set({
|
||||
voiceUsers: new Map(),
|
||||
currentVoiceChannelId: null,
|
||||
|
||||
@@ -11,6 +11,8 @@ interface VoiceState {
|
||||
participants: ParticipantInfo[];
|
||||
connectionError: string | null;
|
||||
isLiveKitConnected: boolean;
|
||||
inputVolume: number; // 0-200 (100 = default)
|
||||
outputVolume: number; // 0-200 (100 = default)
|
||||
setVoiceUsers: (channelId: string, userIds: string[]) => void;
|
||||
addVoiceUser: (channelId: string, userId: string) => void;
|
||||
removeVoiceUser: (channelId: string, userId: string) => void;
|
||||
@@ -18,11 +20,15 @@ interface VoiceState {
|
||||
setParticipants: (participants: ParticipantInfo[]) => void;
|
||||
setConnectionError: (error: string | null) => void;
|
||||
setIsLiveKitConnected: (connected: boolean) => void;
|
||||
setInputVolume: (volume: number) => void;
|
||||
setOutputVolume: (volume: number) => void;
|
||||
toggleMic: () => void;
|
||||
toggleCamera: () => void;
|
||||
toggleScreenShare: () => void;
|
||||
toggleDeafen: () => void;
|
||||
getVoiceUsers: (channelId: string) => string[];
|
||||
clearAllVoiceUsers: () => void;
|
||||
leaveVoice: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -36,6 +42,8 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
||||
participants: [],
|
||||
connectionError: null,
|
||||
isLiveKitConnected: false,
|
||||
inputVolume: 100,
|
||||
outputVolume: 100,
|
||||
|
||||
setVoiceUsers: (channelId, userIds) => {
|
||||
set((state) => {
|
||||
@@ -71,6 +79,9 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
||||
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 })),
|
||||
@@ -78,6 +89,22 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
||||
|
||||
getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [],
|
||||
|
||||
clearAllVoiceUsers: () => set({ voiceUsers: new Map() }),
|
||||
|
||||
// Leave voice without wiping the voiceUsers map (so sidebar still shows others)
|
||||
leaveVoice: () => set({
|
||||
currentVoiceChannelId: null,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isCameraOn: false,
|
||||
isScreenSharing: false,
|
||||
participants: [],
|
||||
connectionError: null,
|
||||
isLiveKitConnected: false,
|
||||
inputVolume: 100,
|
||||
outputVolume: 100,
|
||||
}),
|
||||
|
||||
reset: () => set({
|
||||
voiceUsers: new Map(),
|
||||
currentVoiceChannelId: null,
|
||||
@@ -88,5 +115,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
||||
participants: [],
|
||||
connectionError: null,
|
||||
isLiveKitConnected: false,
|
||||
inputVolume: 100,
|
||||
outputVolume: 100,
|
||||
}),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user