diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index 55d9604a..812948cf 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -12,6 +12,7 @@ import type { DmMessageWithUser, CreateDmRequest, CreateDmMessageRequest, + AddDmMemberRequest, PaginatedQuery, } from '@opencord/shared'; @@ -194,6 +195,15 @@ export async function dmRoutes(app: FastifyInstance): Promise { .get(); if (otherMember) { + // Only match 1-on-1 DMs (exactly 2 members). Skip group DMs that + // happen to include the target user to avoid returning the wrong channel. + const memberCount = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, myDm.dmChannelId)) + .all() + .length; + if (memberCount !== 2) continue; + // DM channel already exists between these users const dmChannel = db.select() .from(schema.dmChannels) @@ -325,6 +335,215 @@ export async function dmRoutes(app: FastifyInstance): Promise { return reply.code(200).send({ success: true }); }); + // POST /api/dm/:id/members - Add a user to an existing DM channel (group DM upgrade) + app.post<{ Params: { id: string }; Body: AddDmMemberRequest }>('/api/dm/:id/members', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const { userId: targetUserId } = request.body; + + if (!targetUserId || typeof targetUserId !== 'string') { + return reply.code(400).send({ error: 'userId is required', statusCode: 400 }); + } + + const db = getDb(); + + // Validate caller is a member + if (!isDmMember(id, request.userId)) { + return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 }); + } + + // Validate target user exists + const targetUser = db.select().from(schema.users).where(eq(schema.users.id, targetUserId)).get(); + if (!targetUser) { + return reply.code(404).send({ error: 'User not found', statusCode: 404 }); + } + + // Validate target is not already a member + const existingMembership = db.select() + .from(schema.dmMembers) + .where(and( + eq(schema.dmMembers.dmChannelId, id), + eq(schema.dmMembers.userId, targetUserId), + )) + .get(); + + if (existingMembership) { + return reply.code(400).send({ error: 'User is already a member of this DM channel', statusCode: 400 }); + } + + // Validate member count < 10 + const currentMembers = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, id)) + .all(); + + if (currentMembers.length >= 10) { + return reply.code(400).send({ error: 'Group DM cannot exceed 10 members', statusCode: 400 }); + } + + // Insert dm_members row for new user + db.insert(schema.dmMembers).values({ + dmChannelId: id, + userId: targetUserId, + }).run(); + + // Build full DmChannel response with all members + const allMemberRows = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, id)) + .all(); + const memberUserIds = allMemberRows.map(m => m.userId); + const users = memberUserIds.length > 0 + ? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all() + : []; + + const dmChannel = db.select() + .from(schema.dmChannels) + .where(eq(schema.dmChannels.id, id)) + .get(); + + if (!dmChannel) { + return reply.code(404).send({ error: 'DM channel not found', statusCode: 404 }); + } + + // Fetch last message + const lastMsgRows = db.select() + .from(schema.dmMessages) + .where(eq(schema.dmMessages.dmChannelId, id)) + .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: lastMsg ? { + id: lastMsg.id, + dmChannelId: lastMsg.dmChannelId, + userId: lastMsg.userId, + content: lastMsg.content, + createdAt: lastMsg.createdAt, + } : null, + }; + + const newUser = sanitizeUser(targetUser); + + // Broadcast dm_member_added to all existing members (before the new one) + for (const member of currentMembers) { + connectionManager.sendToUser(member.userId, { + type: 'dm_member_added', + dmChannelId: id, + user: newUser, + }); + } + + // Send dm_channel_created to the new member so their sidebar picks it up + connectionManager.sendToUser(targetUserId, { + type: 'dm_channel_created', + dmChannel: result, + }); + + // Active call sync: if there's an active call in this DM, notify the new member + const room = connectionManager.getRoom(id); + if (room && room.roomType === 'dm') { + const meta = room.metadata as { state: string; callerId: string }; + if (meta.state === 'active' || meta.state === 'ringing') { + // Look up caller name + const callerRow = db.select().from(schema.users).where(eq(schema.users.id, meta.callerId)).get(); + const callerName = callerRow?.displayName ?? callerRow?.username ?? meta.callerId; + connectionManager.sendToUser(targetUserId, { + type: 'dm_call_incoming', + dmChannelId: id, + callerId: meta.callerId, + callerName, + }); + } + } + + return reply.code(200).send(result); + }); + + // DELETE /api/dm/:id/members - Leave a group DM + app.delete<{ Params: { id: string } }>('/api/dm/:id/members', { + preHandler: authenticate, + }, async (request, reply) => { + const { id } = request.params; + const db = getDb(); + + // Validate caller is a member + if (!isDmMember(id, request.userId)) { + return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 }); + } + + // Count members — can't leave a 1-on-1 + const memberRows = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, id)) + .all(); + + if (memberRows.length <= 2) { + return reply.code(400).send({ error: 'Cannot leave a 1-on-1 DM. Use close instead.', statusCode: 400 }); + } + + // If user is in this DM's VoiceRoom, leave it first + const userRoom = connectionManager.getUserRoom(request.userId); + if (userRoom && userRoom.roomId === id) { + const left = connectionManager.leaveCurrentRoom(request.userId); + if (left) { + // Broadcast voice leave + connectionManager.sendToDmMembers(id, { + type: 'voice_state_update', + channelId: id, + userId: request.userId, + action: 'leave', + }); + // Auto-end call if DM room is now empty + const updatedRoom = connectionManager.getRoom(id); + if (updatedRoom && updatedRoom.participants.size === 0) { + connectionManager.destroyRoom(id); + connectionManager.sendToDmMembers(id, { + type: 'dm_call_ended', + dmChannelId: id, + }); + } + } + connectionManager.clearVoiceUserStatus(request.userId); + } + + // Delete dm_members row + db.delete(schema.dmMembers) + .where(and( + eq(schema.dmMembers.dmChannelId, id), + eq(schema.dmMembers.userId, request.userId), + )) + .run(); + + // Broadcast dm_member_removed to remaining members + const remainingMembers = db.select() + .from(schema.dmMembers) + .where(eq(schema.dmMembers.dmChannelId, id)) + .all(); + + for (const member of remainingMembers) { + connectionManager.sendToUser(member.userId, { + type: 'dm_member_removed', + dmChannelId: id, + userId: request.userId, + }); + } + + // Send dm_channel_closed to the leaving user + connectionManager.sendToUser(request.userId, { + type: 'dm_channel_closed', + dmChannelId: id, + }); + + return reply.code(200).send({ success: true }); + }); + // GET /api/dm/:id/messages - Get DM messages with pagination app.get<{ Params: { id: string }; Querystring: PaginatedQuery }>('/api/dm/:id/messages', { preHandler: authenticate, diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index a21f4b11..11876cfa 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -902,21 +902,37 @@ function handleDmCallAccept(event: Record, userId: string): voi const meta = room.metadata as DmRoomMeta; - // Activate the room (ringing → active) - connectionManager.activateDmRoom(dmChannelId); + if (meta.state === 'ringing') { + // First accept — transition ringing→active and join the caller + connectionManager.activateDmRoom(dmChannelId); - // Leave current rooms for both caller and acceptor - const callerLeft = connectionManager.leaveCurrentRoom(meta.callerId); - if (callerLeft) { - broadcastRoomLeave(callerLeft.roomId, callerLeft.room, meta.callerId); + // Leave caller's current room if in one + const callerLeft = connectionManager.leaveCurrentRoom(meta.callerId); + if (callerLeft) { + broadcastRoomLeave(callerLeft.roomId, callerLeft.room, meta.callerId); + } + + // Join caller into the DM room + connectionManager.joinRoom(dmChannelId, meta.callerId); + + // Broadcast voice_state_update join for caller + connectionManager.sendToDmMembers(dmChannelId, { + type: 'voice_state_update', + channelId: dmChannelId, + userId: meta.callerId, + action: 'join', + }); } + // If already active, this is a late-join (e.g. 3rd member joining group call). + // Skip the ringing→active transition and caller join — just join the acceptor below. + + // Leave acceptor's current room if in one const acceptorLeft = connectionManager.leaveCurrentRoom(userId); if (acceptorLeft) { broadcastRoomLeave(acceptorLeft.roomId, acceptorLeft.room, userId); } - // Join both participants - connectionManager.joinRoom(dmChannelId, meta.callerId); + // Join acceptor into the DM room connectionManager.joinRoom(dmChannelId, userId); // Notify all DM members that the call was accepted @@ -925,14 +941,7 @@ function handleDmCallAccept(event: Record, userId: string): voi dmChannelId, }); - // Broadcast voice_state_update join for both participants - // This populates the frontend's voiceUsers map generically - connectionManager.sendToDmMembers(dmChannelId, { - type: 'voice_state_update', - channelId: dmChannelId, - userId: meta.callerId, - action: 'join', - }); + // Broadcast voice_state_update join for acceptor connectionManager.sendToDmMembers(dmChannelId, { type: 'voice_state_update', channelId: dmChannelId, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 9855c38b..eec964c5 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -226,6 +226,8 @@ export type ServerEvent = | { type: 'voice_status_update'; userId: string; channelId: string; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean } | { type: 'dm_channel_created'; dmChannel: DmChannel } | { type: 'dm_channel_closed'; dmChannelId: string } + | { type: 'dm_member_added'; dmChannelId: string; user: User } + | { type: 'dm_member_removed'; dmChannelId: string; userId: string } | { type: 'friend_removed'; userId: string } | { type: 'channel_created'; channel: Channel; serverId: string } | { type: 'channel_updated'; channel: Channel; serverId: string } @@ -312,6 +314,10 @@ export interface CreateDmRequest { userId: string; } +export interface AddDmMemberRequest { + userId: string; +} + export interface CreateDmMessageRequest { content: string; } diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index c2e5df50..87980f32 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -162,6 +162,10 @@ export const api = { request('PATCH', `/dm/messages/${id}`, data), deleteMessage: (id: string) => request<{ success: boolean }>('DELETE', `/dm/messages/${id}`), + addMember: (dmChannelId: string, data: { userId: string }) => + request('POST', `/dm/${dmChannelId}/members`, data), + leave: (dmChannelId: string) => + request<{ success: boolean }>('DELETE', `/dm/${dmChannelId}/members`), }, social: { diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index 158cf171..5efcc00d 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -13,6 +13,7 @@ import { InviteModal } from '../modals/InviteModal'; import { UserSettingsModal } from '../modals/UserSettings'; import { ServerSettingsModal } from '../modals/ServerSettings'; import { NewDmModal } from '../modals/NewDmModal'; +import { AddDmMemberModal } from '../modals/AddDmMemberModal'; import { IncomingCallModal } from '../voice/IncomingCallModal'; import { PictureInPicture } from '../voice/PictureInPicture'; import { SoundController } from '../voice/SoundController'; @@ -236,6 +237,7 @@ export function AppLayout() { + diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index 6f064bd3..401c778e 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -176,10 +176,15 @@ export function ChannelSidebar() {
{dmChannels.map((dm) => { - const otherUser = dm.members.find(m => m.id !== user?.id); - if (!otherUser) return null; + const otherMembers = dm.members.filter(m => m.id !== user?.id); + if (otherMembers.length === 0) return null; + const isGroup = dm.members.length > 2; const isDmUnread = unreadChannels.has(dm.id) && currentChannelId !== dm.id; + const dmDisplayName = isGroup + ? otherMembers.map(m => m.displayName ?? m.username).join(', ') + : otherMembers[0]?.displayName ?? otherMembers[0]?.username; + return (
)} - + {isGroup ? ( +
+ {otherMembers.slice(0, 2).map((m, i) => ( +
+ +
+ ))} +
+ ) : ( + + )}
- {otherUser.displayName ?? otherUser.username} + {dmDisplayName}
- {dm.lastMessage && ( + {isGroup ? ( +
+ {dm.members.length} Members +
+ ) : dm.lastMessage ? (
{dm.lastMessage.content}
- )} + ) : null}
- + ))} +
+
+ + ); +} diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index f8bbaf9b..10bb2b23 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -261,6 +261,18 @@ function handleEvent(event: ServerEvent): void { removeDmChannel(event.dmChannelId); break; + case 'dm_member_added': { + const { addDmMember } = useServerStore.getState(); + addDmMember(event.dmChannelId, event.user); + break; + } + + case 'dm_member_removed': { + const { removeDmMember } = useServerStore.getState(); + removeDmMember(event.dmChannelId, event.userId); + break; + } + case 'friend_removed': { const { removeFriendLocally } = useSocialStore.getState(); removeFriendLocally(event.userId); diff --git a/packages/web/src/stores/serverStore.ts b/packages/web/src/stores/serverStore.ts index e2f405cc..5ff8b0ec 100644 --- a/packages/web/src/stores/serverStore.ts +++ b/packages/web/src/stores/serverStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers, Role, ServerFolder, DmChannel } from '@opencord/shared'; +import type { Server, Channel, MemberWithUser, ServerWithChannelsAndMembers, Role, ServerFolder, DmChannel, User } from '@opencord/shared'; import { api } from '../api/client'; interface ServerState { @@ -20,6 +20,8 @@ interface ServerState { setDmChannels: (channels: DmChannel[]) => void; addDmChannel: (channel: DmChannel) => void; removeDmChannel: (id: string) => void; + addDmMember: (dmChannelId: string, user: User) => void; + removeDmMember: (dmChannelId: string, userId: string) => void; closeDm: (id: string) => Promise; loadServers: () => Promise; loadServerDetail: (serverId: string) => Promise; @@ -66,6 +68,22 @@ export const useServerStore = create((set, get) => ({ dmChannels: state.dmChannels.filter(c => c.id !== id) })), + addDmMember: (dmChannelId, user) => set((state) => ({ + dmChannels: state.dmChannels.map(dm => + dm.id === dmChannelId + ? { ...dm, members: dm.members.some(m => m.id === user.id) ? dm.members : [...dm.members, user] } + : dm + ), + })), + + removeDmMember: (dmChannelId, userId) => set((state) => ({ + dmChannels: state.dmChannels.map(dm => + dm.id === dmChannelId + ? { ...dm, members: dm.members.filter(m => m.id !== userId) } + : dm + ), + })), + closeDm: async (id) => { await api.dm.close(id); set((state) => ({ diff --git a/packages/web/src/stores/uiStore.ts b/packages/web/src/stores/uiStore.ts index 3f281a4e..78dfaaa4 100644 --- a/packages/web/src/stores/uiStore.ts +++ b/packages/web/src/stores/uiStore.ts @@ -11,6 +11,7 @@ type ModalType = | 'serverSettings' | 'imagePreview' | 'newDm' + | 'addDmMember' | null; interface UIState {