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:
@@ -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() {
|
||||
<UserSettingsModal />
|
||||
<ServerSettingsModal />
|
||||
<NewDmModal />
|
||||
<AddDmMemberModal />
|
||||
<IncomingCallModal />
|
||||
<ImagePreview />
|
||||
<PictureInPicture />
|
||||
|
||||
@@ -176,10 +176,15 @@ export function ChannelSidebar() {
|
||||
|
||||
<div className="space-y-[2px]">
|
||||
{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 (
|
||||
<div
|
||||
key={dm.id}
|
||||
@@ -195,20 +200,43 @@ export function ChannelSidebar() {
|
||||
{isDmUnread && (
|
||||
<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={`text-[15px] truncate leading-tight ${
|
||||
currentChannelId === dm.id ? 'text-white font-medium'
|
||||
: isDmUnread ? 'text-white font-bold'
|
||||
: 'text-discord-text-muted group-hover:text-discord-text-secondary font-medium'
|
||||
}`}>
|
||||
{otherUser.displayName ?? otherUser.username}
|
||||
{dmDisplayName}
|
||||
</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">
|
||||
{dm.lastMessage.content}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -32,7 +32,8 @@ export function MainContent() {
|
||||
const outgoingCall = useVoiceStore((s) => s.outgoingCall);
|
||||
const dmChannels = useServerStore((s) => s.dmChannels);
|
||||
const authUser = useAuthStore((s) => s.user);
|
||||
|
||||
const openModal = useUIStore((s) => s.openModal);
|
||||
|
||||
const voiceContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Handle actual browser fullscreen API
|
||||
@@ -64,8 +65,11 @@ export function MainContent() {
|
||||
}
|
||||
|
||||
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 otherMembers = dmChannel?.members.filter(m => m.id !== authUser?.id) ?? [];
|
||||
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 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" />
|
||||
</svg>
|
||||
<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 className="flex items-center gap-1 flex-shrink-0">
|
||||
<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" />
|
||||
</svg>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user