feat: add group DM support (3-10 members)

Unlock group DMs by removing the 2-member assumption across the stack.
No schema migration needed — dm_members junction table already supports
N members. Includes dedup bug fix, add/leave member endpoints, late-join
call support, group-aware sidebar rendering, and AddDmMemberModal.
This commit is contained in:
Jannis Braun
2026-02-23 22:39:06 +01:00
parent 653e59bfb2
commit 900eb67869
11 changed files with 475 additions and 27 deletions
+219
View File
@@ -12,6 +12,7 @@ import type {
DmMessageWithUser, DmMessageWithUser,
CreateDmRequest, CreateDmRequest,
CreateDmMessageRequest, CreateDmMessageRequest,
AddDmMemberRequest,
PaginatedQuery, PaginatedQuery,
} from '@opencord/shared'; } from '@opencord/shared';
@@ -194,6 +195,15 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
.get(); .get();
if (otherMember) { 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 // DM channel already exists between these users
const dmChannel = db.select() const dmChannel = db.select()
.from(schema.dmChannels) .from(schema.dmChannels)
@@ -325,6 +335,215 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send({ success: true }); 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 // GET /api/dm/:id/messages - Get DM messages with pagination
app.get<{ Params: { id: string }; Querystring: PaginatedQuery }>('/api/dm/:id/messages', { app.get<{ Params: { id: string }; Querystring: PaginatedQuery }>('/api/dm/:id/messages', {
preHandler: authenticate, preHandler: authenticate,
+25 -16
View File
@@ -902,21 +902,37 @@ function handleDmCallAccept(event: Record<string, unknown>, userId: string): voi
const meta = room.metadata as DmRoomMeta; const meta = room.metadata as DmRoomMeta;
// Activate the room (ringing → active) if (meta.state === 'ringing') {
connectionManager.activateDmRoom(dmChannelId); // First accept — transition ringing→active and join the caller
connectionManager.activateDmRoom(dmChannelId);
// Leave current rooms for both caller and acceptor // Leave caller's current room if in one
const callerLeft = connectionManager.leaveCurrentRoom(meta.callerId); const callerLeft = connectionManager.leaveCurrentRoom(meta.callerId);
if (callerLeft) { if (callerLeft) {
broadcastRoomLeave(callerLeft.roomId, callerLeft.room, meta.callerId); 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); const acceptorLeft = connectionManager.leaveCurrentRoom(userId);
if (acceptorLeft) { if (acceptorLeft) {
broadcastRoomLeave(acceptorLeft.roomId, acceptorLeft.room, userId); broadcastRoomLeave(acceptorLeft.roomId, acceptorLeft.room, userId);
} }
// Join both participants // Join acceptor into the DM room
connectionManager.joinRoom(dmChannelId, meta.callerId);
connectionManager.joinRoom(dmChannelId, userId); connectionManager.joinRoom(dmChannelId, userId);
// Notify all DM members that the call was accepted // Notify all DM members that the call was accepted
@@ -925,14 +941,7 @@ function handleDmCallAccept(event: Record<string, unknown>, userId: string): voi
dmChannelId, dmChannelId,
}); });
// Broadcast voice_state_update join for both participants // Broadcast voice_state_update join for acceptor
// This populates the frontend's voiceUsers map generically
connectionManager.sendToDmMembers(dmChannelId, {
type: 'voice_state_update',
channelId: dmChannelId,
userId: meta.callerId,
action: 'join',
});
connectionManager.sendToDmMembers(dmChannelId, { connectionManager.sendToDmMembers(dmChannelId, {
type: 'voice_state_update', type: 'voice_state_update',
channelId: dmChannelId, channelId: dmChannelId,
+6
View File
@@ -226,6 +226,8 @@ export type ServerEvent =
| { type: 'voice_status_update'; userId: string; channelId: string; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean } | { 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_created'; dmChannel: DmChannel }
| { type: 'dm_channel_closed'; dmChannelId: string } | { 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: 'friend_removed'; userId: string }
| { type: 'channel_created'; channel: Channel; serverId: string } | { type: 'channel_created'; channel: Channel; serverId: string }
| { type: 'channel_updated'; channel: Channel; serverId: string } | { type: 'channel_updated'; channel: Channel; serverId: string }
@@ -312,6 +314,10 @@ export interface CreateDmRequest {
userId: string; userId: string;
} }
export interface AddDmMemberRequest {
userId: string;
}
export interface CreateDmMessageRequest { export interface CreateDmMessageRequest {
content: string; content: string;
} }
+4
View File
@@ -162,6 +162,10 @@ export const api = {
request<DmMessageWithUser>('PATCH', `/dm/messages/${id}`, data), request<DmMessageWithUser>('PATCH', `/dm/messages/${id}`, data),
deleteMessage: (id: string) => deleteMessage: (id: string) =>
request<{ success: boolean }>('DELETE', `/dm/messages/${id}`), request<{ success: boolean }>('DELETE', `/dm/messages/${id}`),
addMember: (dmChannelId: string, data: { userId: string }) =>
request<DmChannel>('POST', `/dm/${dmChannelId}/members`, data),
leave: (dmChannelId: string) =>
request<{ success: boolean }>('DELETE', `/dm/${dmChannelId}/members`),
}, },
social: { social: {
@@ -13,6 +13,7 @@ import { InviteModal } from '../modals/InviteModal';
import { UserSettingsModal } from '../modals/UserSettings'; import { UserSettingsModal } from '../modals/UserSettings';
import { ServerSettingsModal } from '../modals/ServerSettings'; import { ServerSettingsModal } from '../modals/ServerSettings';
import { NewDmModal } from '../modals/NewDmModal'; import { NewDmModal } from '../modals/NewDmModal';
import { AddDmMemberModal } from '../modals/AddDmMemberModal';
import { IncomingCallModal } from '../voice/IncomingCallModal'; import { IncomingCallModal } from '../voice/IncomingCallModal';
import { PictureInPicture } from '../voice/PictureInPicture'; import { PictureInPicture } from '../voice/PictureInPicture';
import { SoundController } from '../voice/SoundController'; import { SoundController } from '../voice/SoundController';
@@ -236,6 +237,7 @@ export function AppLayout() {
<UserSettingsModal /> <UserSettingsModal />
<ServerSettingsModal /> <ServerSettingsModal />
<NewDmModal /> <NewDmModal />
<AddDmMemberModal />
<IncomingCallModal /> <IncomingCallModal />
<ImagePreview /> <ImagePreview />
<PictureInPicture /> <PictureInPicture />
@@ -176,10 +176,15 @@ export function ChannelSidebar() {
<div className="space-y-[2px]"> <div className="space-y-[2px]">
{dmChannels.map((dm) => { {dmChannels.map((dm) => {
const otherUser = dm.members.find(m => m.id !== user?.id); const otherMembers = dm.members.filter(m => m.id !== user?.id);
if (!otherUser) return null; if (otherMembers.length === 0) return null;
const isGroup = dm.members.length > 2;
const isDmUnread = unreadChannels.has(dm.id) && currentChannelId !== dm.id; 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 ( return (
<div <div
key={dm.id} key={dm.id}
@@ -195,20 +200,43 @@ export function ChannelSidebar() {
{isDmUnread && ( {isDmUnread && (
<div className="absolute -left-1 w-1 h-2 bg-white rounded-r-full" /> <div className="absolute -left-1 w-1 h-2 bg-white rounded-r-full" />
)} )}
<Avatar src={otherUser.avatar} name={otherUser.displayName ?? otherUser.username} size={32} status={otherUser.status as any} /> {isGroup ? (
<div className="relative w-8 h-8 flex-shrink-0">
{otherMembers.slice(0, 2).map((m, i) => (
<div
key={m.id}
className="absolute rounded-full overflow-hidden border-2 border-discord-bg-secondary"
style={{
width: 22, height: 22,
left: i * 10,
top: i * 6,
zIndex: 2 - i,
}}
>
<Avatar src={m.avatar} name={m.displayName ?? m.username} size={22} />
</div>
))}
</div>
) : (
<Avatar src={otherMembers[0]?.avatar} name={otherMembers[0]?.displayName ?? otherMembers[0]?.username ?? ''} size={32} status={otherMembers[0]?.status as any} />
)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className={`text-[15px] truncate leading-tight ${ <div className={`text-[15px] truncate leading-tight ${
currentChannelId === dm.id ? 'text-white font-medium' currentChannelId === dm.id ? 'text-white font-medium'
: isDmUnread ? 'text-white font-bold' : isDmUnread ? 'text-white font-bold'
: 'text-discord-text-muted group-hover:text-discord-text-secondary font-medium' : 'text-discord-text-muted group-hover:text-discord-text-secondary font-medium'
}`}> }`}>
{otherUser.displayName ?? otherUser.username} {dmDisplayName}
</div> </div>
{dm.lastMessage && ( {isGroup ? (
<div className="text-[12px] text-discord-channels-default truncate leading-tight mt-0.5">
{dm.members.length} Members
</div>
) : dm.lastMessage ? (
<div className="text-[12px] text-discord-channels-default truncate leading-tight mt-0.5"> <div className="text-[12px] text-discord-channels-default truncate leading-tight mt-0.5">
{dm.lastMessage.content} {dm.lastMessage.content}
</div> </div>
)} ) : null}
</div> </div>
<button <button
onClick={(e) => { onClick={(e) => {
@@ -32,6 +32,7 @@ export function MainContent() {
const outgoingCall = useVoiceStore((s) => s.outgoingCall); const outgoingCall = useVoiceStore((s) => s.outgoingCall);
const dmChannels = useServerStore((s) => s.dmChannels); const dmChannels = useServerStore((s) => s.dmChannels);
const authUser = useAuthStore((s) => s.user); const authUser = useAuthStore((s) => s.user);
const openModal = useUIStore((s) => s.openModal);
const voiceContainerRef = useRef<HTMLDivElement>(null); const voiceContainerRef = useRef<HTMLDivElement>(null);
@@ -64,8 +65,11 @@ export function MainContent() {
} }
const dmChannel = dmChannels.find(dm => dm.id === currentChannelId); const dmChannel = dmChannels.find(dm => dm.id === currentChannelId);
const otherUser = dmChannel?.members.find(m => m.id !== authUser?.id); const otherMembers = dmChannel?.members.filter(m => m.id !== authUser?.id) ?? [];
const dmName = otherUser?.displayName ?? otherUser?.username ?? 'Direct Message'; const isGroupDm = (dmChannel?.members.length ?? 0) > 2;
const dmName = isGroupDm
? otherMembers.map(m => m.displayName ?? m.username).join(', ')
: otherMembers[0]?.displayName ?? otherMembers[0]?.username ?? 'Direct Message';
const isInDmCall = activeDmCall?.dmChannelId === currentChannelId; const isInDmCall = activeDmCall?.dmChannelId === currentChannelId;
const isCallingThisDm = outgoingCall?.dmChannelId === currentChannelId; const isCallingThisDm = outgoingCall?.dmChannelId === currentChannelId;
@@ -146,6 +150,9 @@ export function MainContent() {
<path d="M12.5 2A6.5 6.5 0 0 0 6 8.5c0 1.82.75 3.47 1.95 4.65A10.02 10.02 0 0 0 2 22h2c0-4.42 3.58-8 8-8 .35 0 .69.03 1.03.07A6.49 6.49 0 0 0 19 8.5 6.5 6.5 0 0 0 12.5 2Zm0 11A4.5 4.5 0 1 1 17 8.5a4.5 4.5 0 0 1-4.5 4.5Z" /> <path d="M12.5 2A6.5 6.5 0 0 0 6 8.5c0 1.82.75 3.47 1.95 4.65A10.02 10.02 0 0 0 2 22h2c0-4.42 3.58-8 8-8 .35 0 .69.03 1.03.07A6.49 6.49 0 0 0 19 8.5 6.5 6.5 0 0 0 12.5 2Zm0 11A4.5 4.5 0 1 1 17 8.5a4.5 4.5 0 0 1-4.5 4.5Z" />
</svg> </svg>
<span className="font-bold text-discord-text-primary truncate">{dmName}</span> <span className="font-bold text-discord-text-primary truncate">{dmName}</span>
{isGroupDm && (
<span className="text-xs text-discord-text-muted flex-shrink-0">({dmChannel?.members.length} Members)</span>
)}
</div> </div>
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
<button <button
@@ -174,7 +181,11 @@ export function MainContent() {
<path d="M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z" /> <path d="M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z" />
</svg> </svg>
</button> </button>
<button className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover" title="Add Friends to DM"> <button
onClick={() => openModal('addDmMember', { dmChannelId: currentChannelId })}
className="w-8 h-8 flex items-center justify-center text-discord-text-muted hover:text-discord-text-primary transition-colors rounded-[4px] hover:bg-discord-modifier-hover"
title="Add Friends to DM"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" /> <path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" />
</svg> </svg>
@@ -0,0 +1,138 @@
import React, { useState, useRef, useEffect } from 'react';
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 AddDmMemberModal() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<User[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [error, setError] = useState('');
const [isAdding, setIsAdding] = useState(false);
const activeModal = useUIStore((s) => s.activeModal);
const modalData = useUIStore((s) => s.modalData);
const closeModal = useUIStore((s) => s.closeModal);
const dmChannels = useServerStore((s) => s.dmChannels);
const inputRef = useRef<HTMLInputElement>(null);
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
const isOpen = activeModal === 'addDmMember';
const dmChannelId = modalData.dmChannelId as string | undefined;
const dmChannel = dmChannels.find(dm => dm.id === dmChannelId);
const currentMemberIds = new Set(dmChannel?.members.map(m => m.id) ?? []);
const memberCount = dmChannel?.members.length ?? 0;
useEffect(() => {
if (isOpen) {
setQuery('');
setResults([]);
setError('');
setIsAdding(false);
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());
// Filter out users already in the DM
setResults(users.filter(u => !currentMemberIds.has(u.id)));
} catch {
setResults([]);
} finally {
setIsSearching(false);
}
}, 300);
};
const handleSelectUser = async (user: User) => {
if (!dmChannelId || isAdding) return;
setError('');
setIsAdding(true);
try {
await api.dm.addMember(dmChannelId, { userId: user.id });
closeModal();
} catch (err) {
setError((err as Error).message || 'Failed to add member');
} finally {
setIsAdding(false);
}
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Add Friends to DM">
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-[13px] text-discord-text-muted">
Search for a user to add to this conversation.
</p>
<span className="text-[12px] text-discord-text-muted flex-shrink-0 ml-2">
{memberCount}/10
</span>
</div>
<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"
disabled={memberCount >= 10}
/>
{memberCount >= 10 && (
<p className="text-discord-red text-[13px]">This group DM has reached the 10-member limit.</p>
)}
{error && (
<p className="text-discord-red text-[13px]">{error}</p>
)}
<div className="max-h-[300px] overflow-y-auto space-y-[2px]">
{isSearching && (
<div className="py-4 text-center text-discord-text-muted text-[14px]">Searching...</div>
)}
{!isSearching && query.trim().length >= 2 && results.length === 0 && (
<div className="py-4 text-center text-discord-text-muted text-[14px]">No users found</div>
)}
{results.map((user) => (
<button
key={user.id}
onClick={() => handleSelectUser(user)}
disabled={isAdding}
className="w-full flex items-center gap-3 px-3 py-2 rounded-[4px] hover:bg-discord-modifier-hover transition-colors text-left disabled:opacity-50"
>
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={36} status={user.status as any} />
<div className="flex-1 min-w-0">
<div className="text-[14px] font-medium text-discord-text-primary truncate">
{user.displayName ?? user.username}
</div>
<div className="text-[12px] text-discord-text-muted truncate">@{user.username}</div>
</div>
</button>
))}
</div>
</div>
</Modal>
);
}
+12
View File
@@ -261,6 +261,18 @@ function handleEvent(event: ServerEvent): void {
removeDmChannel(event.dmChannelId); removeDmChannel(event.dmChannelId);
break; 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': { case 'friend_removed': {
const { removeFriendLocally } = useSocialStore.getState(); const { removeFriendLocally } = useSocialStore.getState();
removeFriendLocally(event.userId); removeFriendLocally(event.userId);
+19 -1
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand'; 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'; import { api } from '../api/client';
interface ServerState { interface ServerState {
@@ -20,6 +20,8 @@ interface ServerState {
setDmChannels: (channels: DmChannel[]) => void; setDmChannels: (channels: DmChannel[]) => void;
addDmChannel: (channel: DmChannel) => void; addDmChannel: (channel: DmChannel) => void;
removeDmChannel: (id: string) => void; removeDmChannel: (id: string) => void;
addDmMember: (dmChannelId: string, user: User) => void;
removeDmMember: (dmChannelId: string, userId: string) => void;
closeDm: (id: string) => Promise<void>; closeDm: (id: string) => Promise<void>;
loadServers: () => Promise<void>; loadServers: () => Promise<void>;
loadServerDetail: (serverId: string) => Promise<void>; loadServerDetail: (serverId: string) => Promise<void>;
@@ -66,6 +68,22 @@ export const useServerStore = create<ServerState>((set, get) => ({
dmChannels: state.dmChannels.filter(c => c.id !== id) 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) => { closeDm: async (id) => {
await api.dm.close(id); await api.dm.close(id);
set((state) => ({ set((state) => ({
+1
View File
@@ -11,6 +11,7 @@ type ModalType =
| 'serverSettings' | 'serverSettings'
| 'imagePreview' | 'imagePreview'
| 'newDm' | 'newDm'
| 'addDmMember'
| null; | null;
interface UIState { interface UIState {