diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index 84487e31..c117e271 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -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, diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index b13c3902..ba341ffd 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -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' } + ] } ]; diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index a90c3ebd..7bb20aa8 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -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' }), diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index bf10778c..cf180443 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -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 { 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 { 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 { 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 }); + }); } diff --git a/packages/server/src/routes/livekit.ts b/packages/server/src/routes/livekit.ts index d8755dab..95944d21 100644 --- a/packages/server/src/routes/livekit.ts +++ b/packages/server/src/routes/livekit.ts @@ -41,9 +41,16 @@ export async function livekitRoutes(app: FastifyInstance): Promise { 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); }); diff --git a/packages/server/src/routes/messages.ts b/packages/server/src/routes/messages.ts index c4e7ab6b..e09220cf 100644 --- a/packages/server/src/routes/messages.ts +++ b/packages/server/src/routes/messages.ts @@ -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 { 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) diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 3b25e6a1..0ef93717 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -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, userId: string): } } +function handleDmTypingStart(event: Record, 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, 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, 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, userId: string): void { const messageId = event.messageId as string; const emoji = event.emoji as string; diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 1a9b8a66..d076d490 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -187,6 +187,7 @@ function buildReadyPayload(userId: string): { servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders: ServerFolder[]; + voiceStates: Record; } { const db = getDb(); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 1fe0942b..444a904e 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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 } | { 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 } diff --git a/packages/web/src/api/client.js b/packages/web/src/api/client.js index 9ecf564e..cca01d47 100644 --- a/packages/web/src/api/client.js +++ b/packages/web/src/api/client.js @@ -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'), diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 85ae1e9f..cc2db245 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -157,6 +157,10 @@ export const api = { }, sendMessage: (id: string, data: CreateDmMessageRequest) => request('POST', `/dm/${id}/messages`, data), + updateMessage: (id: string, data: UpdateMessageRequest) => + request('PATCH', `/dm/messages/${id}`, data), + deleteMessage: (id: string) => + request<{ success: boolean }>('DELETE', `/dm/messages/${id}`), }, social: { diff --git a/packages/web/src/components/chat/Message.js b/packages/web/src/components/chat/Message.js index 7a9b0da8..5f08b2ac 100644 --- a/packages/web/src/components/chat/Message.js +++ b/packages/web/src/components/chat/Message.js @@ -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)); diff --git a/packages/web/src/components/chat/Message.tsx b/packages/web/src/components/chat/Message.tsx index 23a19485..727f9868 100644 --- a/packages/web/src/components/chat/Message.tsx +++ b/packages/web/src/components/chat/Message.tsx @@ -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 = (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {/* Reply Line */} {message.replyTo && ( -
+
)} {/* Avatar or timestamp column */} @@ -181,7 +181,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { > {message.replyTo.user.displayName ?? message.replyTo.user.username} - + {message.replyTo.content}
@@ -280,7 +280,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) { {!isEditing && firstUrl && } {/* Attachments */} - {message.attachments.length > 0 && ( + {message.attachments && message.attachments.length > 0 && (
{message.attachments.map((att) => { const isImage = att.mimetype.startsWith('image/'); diff --git a/packages/web/src/components/chat/MessageInput.js b/packages/web/src/components/chat/MessageInput.js index 73f285c2..c8c232dd 100644 --- a/packages/web/src/components/chat/MessageInput.js +++ b/packages/web/src/components/chat/MessageInput.js @@ -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" }) }) })] })] })] })); } diff --git a/packages/web/src/components/chat/MessageInput.tsx b/packages/web/src/components/chat/MessageInput.tsx index e33476a6..2cd7d657 100644 --- a/packages/web/src/components/chat/MessageInput.tsx +++ b/packages/web/src/components/chat/MessageInput.tsx @@ -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 (
{replyTo && ( -
+
Replying to {replyTo.user.displayName ?? replyTo.user.username} @@ -131,7 +137,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) { {files.length > 0 && (
{files.map((file, i) => ( -
+
{file.type.startsWith('image/') ? ( 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" > @@ -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} diff --git a/packages/web/src/components/layout/AppLayout.js b/packages/web/src/components/layout/AppLayout.js index b3e6edca..5d7777ea 100644 --- a/packages/web/src/components/layout/AppLayout.js +++ b/packages/web/src/components/layout/AppLayout.js @@ -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 })] }))] })); } diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index 67677059..d0bc763f 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -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() { + {/* User Profile Popout */} diff --git a/packages/web/src/components/layout/ChannelSidebar.js b/packages/web/src/components/layout/ChannelSidebar.js index cb01ab21..7ce98d84 100644 --- a/packages/web/src/components/layout/ChannelSidebar.js +++ b/packages/web/src/components/layout/ChannelSidebar.js @@ -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 })); } diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 03480a29..213b2eed 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -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() {
Direct Messages -
); @@ -262,47 +268,407 @@ export function ChannelSidebar() { {/* User area */} {user && ( -
-
- -
-
{user.displayName ?? user.username}
-
@{user.username}
-
-
- -
- - - - - - - - - - - - openModal('userSettings')}> - - - - -
-
+ openModal('userSettings')} + /> )}
); } -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([]); + const [outputDevices, setOutputDevices] = useState([]); + const [selectedInput, setSelectedInput] = useState('default'); + const [selectedOutput, setSelectedOutput] = useState('default'); + const [selectedInputLabel, setSelectedInputLabel] = useState('Default'); + const [selectedOutputLabel, setSelectedOutputLabel] = useState('Default'); + const inputVolume = useVoiceStore((s) => s.inputVolume); + const storeSetInputVolume = useVoiceStore((s) => s.setInputVolume); + const outputVolume = useVoiceStore((s) => s.outputVolume); + const storeSetOutputVolume = useVoiceStore((s) => s.setOutputVolume); + const [showInputDeviceList, setShowInputDeviceList] = useState(false); + const [showOutputDeviceList, setShowOutputDeviceList] = useState(false); + const [micLevel, setMicLevel] = useState(0); + const panelRef = useRef(null); + const analyserRef = useRef(null); + const animFrameRef = useRef(0); + + const loadDevices = useCallback(async () => { + try { + // Need to request permission first to get labels + await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop())); + const devices = await navigator.mediaDevices.enumerateDevices(); + setInputDevices(devices.filter(d => d.kind === 'audioinput')); + setOutputDevices(devices.filter(d => d.kind === 'audiooutput')); + } catch { + // permission denied + } + }, []); + + // Start mic level monitoring when input panel opens + useEffect(() => { + if (openPanel !== 'input') { + if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); + analyserRef.current = null; + setMicLevel(0); + return; + } + let stream: 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 ( - +
+ {/* Input settings panel */} + {openPanel === 'input' && ( +
+ {/* Input Device */} +
+ + {showInputDeviceList && ( +
+ {inputDevices.map(d => ( + + ))} +
+ )} +
+ +
+ + {/* Input Volume */} +
+
Input Volume
+ { + 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 */} +
+ {Array.from({ length: micBars }).map((_, i) => ( +
+ ))} +
+
+ +
+ + {/* Voice Settings link */} + +
+ )} + + {/* Output settings panel */} + {openPanel === 'output' && ( +
+ {/* Output Device */} +
+ + {showOutputDeviceList && ( +
+ {outputDevices.map(d => ( + + ))} +
+ )} +
+ +
+ + {/* Output Volume */} +
+
Output Volume
+ { + 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%)`, + }} + /> +
+ +
+ + {/* Voice Settings link */} + +
+ )} + + {/* User area bar */} +
+ {/* Avatar + name */} +
+ +
+
{user.displayName ?? user.username}
+
@{user.username}
+
+
+ + {/* Controls */} +
+ {/* Mic */} + + {/* Input chevron */} + + + {/* Headphones */} + + {/* Output chevron */} + + + {/* Settings */} + +
+
+
); } diff --git a/packages/web/src/components/layout/MainContent.js b/packages/web/src/components/layout/MainContent.js index eba0dc6a..9929e72e 100644 --- a/packages/web/src/components/layout/MainContent.js +++ b/packages/web/src/components/layout/MainContent.js @@ -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) { diff --git a/packages/web/src/components/layout/MainContent.tsx b/packages/web/src/components/layout/MainContent.tsx index 739f73fb..6a4fae45 100644 --- a/packages/web/src/components/layout/MainContent.tsx +++ b/packages/web/src/components/layout/MainContent.tsx @@ -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 ; } - + + 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 (
@ - Direct Message + {otherUser && ( + + )} + {dmName} + {otherUser?.status && otherUser.status !== 'offline' && ( + {otherUser.status} + )}
- +
); } diff --git a/packages/web/src/components/modals/NewDmModal.js b/packages/web/src/components/modals/NewDmModal.js new file mode 100644 index 00000000..4bccbadc --- /dev/null +++ b/packages/web/src/components/modals/NewDmModal.js @@ -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)))] })] }) })); +} diff --git a/packages/web/src/components/modals/NewDmModal.tsx b/packages/web/src/components/modals/NewDmModal.tsx new file mode 100644 index 00000000..77db1cf8 --- /dev/null +++ b/packages/web/src/components/modals/NewDmModal.tsx @@ -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([]); + 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: 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 ( + +
+ 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 && ( +

{error}

+ )} + +
+ {isSearching && ( +
Searching...
+ )} + + {!isSearching && query.trim().length >= 2 && results.length === 0 && ( +
No users found
+ )} + + {results.map((user) => ( + + ))} +
+
+
+ ); +} diff --git a/packages/web/src/components/ui/UserProfilePopout.tsx b/packages/web/src/components/ui/UserProfilePopout.tsx index 7bd17c8a..e7f7bf32 100644 --- a/packages/web/src/components/ui/UserProfilePopout.tsx +++ b/packages/web/src/components/ui/UserProfilePopout.tsx @@ -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) { diff --git a/packages/web/src/hooks/useWebSocket.js b/packages/web/src/hooks/useWebSocket.js index 469a9690..dd8ead10 100644 --- a/packages/web/src/hooks/useWebSocket.js +++ b/packages/web/src/hooks/useWebSocket.js @@ -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); diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index cfb3faf7..fbd3109c 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -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; - 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': diff --git a/packages/web/src/stores/chatStore.js b/packages/web/src/stores/chatStore.js index ef2b9e6b..4b92f380 100644 --- a/packages/web/src/stores/chatStore.js +++ b/packages/web/src/stores/chatStore.js @@ -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 }; }); }, diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 0624bf42..c81241a5 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -114,12 +114,22 @@ export const useChatStore = create((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((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 }; diff --git a/packages/web/src/stores/uiStore.ts b/packages/web/src/stores/uiStore.ts index 1eb32bfb..d6d303c6 100644 --- a/packages/web/src/stores/uiStore.ts +++ b/packages/web/src/stores/uiStore.ts @@ -9,6 +9,7 @@ type ModalType = | 'userSettings' | 'serverSettings' | 'imagePreview' + | 'newDm' | null; interface UIState { diff --git a/packages/web/src/stores/voiceStore.js b/packages/web/src/stores/voiceStore.js index 83e556ec..570d7458 100644 --- a/packages/web/src/stores/voiceStore.js +++ b/packages/web/src/stores/voiceStore.js @@ -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, diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index 48e01368..bacb484a 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -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((set, get) => ({ participants: [], connectionError: null, isLiveKitConnected: false, + inputVolume: 100, + outputVolume: 100, setVoiceUsers: (channelId, userIds) => { set((state) => { @@ -71,6 +79,9 @@ export const useVoiceStore = create((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((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((set, get) => ({ participants: [], connectionError: null, isLiveKitConnected: false, + inputVolume: 100, + outputVolume: 100, }), }));