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:
@@ -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,
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
// First accept — transition ringing→active and join the caller
|
||||||
connectionManager.activateDmRoom(dmChannelId);
|
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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type ModalType =
|
|||||||
| 'serverSettings'
|
| 'serverSettings'
|
||||||
| 'imagePreview'
|
| 'imagePreview'
|
||||||
| 'newDm'
|
| 'newDm'
|
||||||
|
| 'addDmMember'
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
interface UIState {
|
interface UIState {
|
||||||
|
|||||||
Reference in New Issue
Block a user