Add private calls
All checks were successful
Build and Release / build-and-release (push) Successful in 11m59s

This commit is contained in:
Bryan1029384756
2026-02-16 13:46:26 -06:00
parent 73b9da2003
commit 5ca0eeb6a4
13 changed files with 286 additions and 57 deletions

View File

@@ -9,6 +9,3 @@
- Can we add a way to tell the user they are connecting to voice. Like show them its connecting so the user knows something is happening instead of them clicking on the voice stage again and again. - Can we add a way to tell the user they are connecting to voice. Like show them its connecting so the user knows something is happening instead of them clicking on the voice stage again and again.
- Add photo / video albums like Commit https://commet.chat/ - Add photo / video albums like Commit https://commet.chat/
when i do filter image it has some other stuff like a .html file and a video file. In the search results but we shouldent show non images in the image filter. Videos should have its own filter and .html and other files should go under the file filter, but images should not go under the file filter. We should also show the video and file attachment like how we show it in the chat.

View File

@@ -1,7 +1,7 @@
{ {
"name": "@discord-clone/electron", "name": "@discord-clone/electron",
"private": true, "private": true,
"version": "1.0.18", "version": "1.0.19",
"description": "Discord Clone - Electron app", "description": "Discord Clone - Electron app",
"author": "Moyettes", "author": "Moyettes",
"type": "module", "type": "module",

View File

@@ -1,7 +1,7 @@
{ {
"name": "@discord-clone/shared", "name": "@discord-clone/shared",
"private": true, "private": true,
"version": "1.0.18", "version": "1.0.19",
"type": "module", "type": "module",
"main": "src/App.jsx", "main": "src/App.jsx",
"dependencies": { "dependencies": {

View File

@@ -0,0 +1 @@
<svg x="0" y="0" class="icon__9293f" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M2 7.4A5.4 5.4 0 0 1 7.4 2c.36 0 .7.22.83.55l1.93 4.64a1 1 0 0 1-.43 1.25L7 10a8.52 8.52 0 0 0 7 7l1.12-2.24a1 1 0 0 1 1.19-.51l5.06 1.56c.38.11.63.46.63.85C22 19.6 19.6 22 16.66 22h-.37C8.39 22 2 15.6 2 7.71V7.4ZM13 3a1 1 0 0 1 1-1 8 8 0 0 1 8 8 1 1 0 1 1-2 0 6 6 0 0 0-6-6 1 1 0 0 1-1-1Z" class=""></path><path fill="currentColor" d="M13 7a1 1 0 0 1 1-1 4 4 0 0 1 4 4 1 1 0 1 1-2 0 2 2 0 0 0-2-2 1 1 0 0 1-1-1Z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 622 B

View File

@@ -28,6 +28,7 @@ import FriendsIcon from './friends.svg';
import SharingIcon from './sharing.svg'; import SharingIcon from './sharing.svg';
import PersonalMuteIcon from './personal_mute.svg'; import PersonalMuteIcon from './personal_mute.svg';
import ServerMuteIcon from './server_mute.svg'; import ServerMuteIcon from './server_mute.svg';
import CallIcon from './call.svg';
export { export {
AddIcon, AddIcon,
@@ -59,7 +60,8 @@ export {
FriendsIcon, FriendsIcon,
SharingIcon, SharingIcon,
PersonalMuteIcon, PersonalMuteIcon,
ServerMuteIcon ServerMuteIcon,
CallIcon
}; };
export const Icons = { export const Icons = {
@@ -92,5 +94,6 @@ export const Icons = {
Friends: FriendsIcon, Friends: FriendsIcon,
Sharing: SharingIcon, Sharing: SharingIcon,
PersonalMute: PersonalMuteIcon, PersonalMute: PersonalMuteIcon,
ServerMute: ServerMuteIcon ServerMute: ServerMuteIcon,
Call: CallIcon
}; };

View File

@@ -0,0 +1 @@
<svg x="0" y="0" class="icon__9293f" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M14.5 8a3 3 0 1 0-2.7-4.3c-.2.4.06.86.44 1.12a5 5 0 0 1 2.14 3.08c.01.06.06.1.12.1ZM18.44 17.27c.15.43.54.73 1 .73h1.06c.83 0 1.5-.67 1.5-1.5a7.5 7.5 0 0 0-6.5-7.43c-.55-.08-.99.38-1.1.92-.06.3-.15.6-.26.87-.23.58-.05 1.3.47 1.63a9.53 9.53 0 0 1 3.83 4.78ZM12.5 9a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM2 20.5a7.5 7.5 0 0 1 15 0c0 .83-.67 1.5-1.5 1.5a.2.2 0 0 1-.2-.16c-.2-.96-.56-1.87-.88-2.54-.1-.23-.42-.15-.42.1v2.1a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-2.1c0-.25-.31-.33-.42-.1-.32.67-.67 1.58-.88 2.54a.2.2 0 0 1-.2.16A1.5 1.5 0 0 1 2 20.5Z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 745 B

View File

@@ -11,6 +11,8 @@ const ChatHeader = ({
serverName, serverName,
isMobile, isMobile,
onMobileBack, onMobileBack,
onStartCall,
isDMCallActive,
// Search props // Search props
searchQuery, searchQuery,
onSearchQueryChange, onSearchQueryChange,
@@ -63,10 +65,23 @@ const ChatHeader = ({
</button> </button>
</Tooltip> </Tooltip>
)} )}
{isDM && !isMobile && (
<Tooltip text={isDMCallActive ? "Join Call" : "Start Call"} position="bottom">
<button
className={`chat-header-btn ${isDMCallActive ? 'active' : ''}`}
onClick={onStartCall}
style={isDMCallActive ? { color: '#3ba55c' } : undefined}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M6.62 10.79a15.053 15.053 0 006.59 6.59l2.2-2.2a1.003 1.003 0 011.01-.24c1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1C10.07 21 3 13.93 3 4c0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.1.31.03.66-.25 1.02l-2.2 2.2z"/>
</svg>
</button>
</Tooltip>
)}
<Tooltip text="Pinned Messages" position="bottom"> <Tooltip text="Pinned Messages" position="bottom">
<button className="chat-header-btn" onClick={onTogglePinned}> <button className="chat-header-btn" onClick={onTogglePinned}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M19.3 5.3a1 1 0 00-1.4-1.4L14.6 7.2l-1.5-.8a2 2 0 00-2.2.2L8.5 9a1 1 0 000 1.5l1.8 1.8-4.6 4.6a1 1 0 001.4 1.4l4.6-4.6 1.8 1.8a1 1 0 001.5 0l2.4-2.4a2 2 0 00.2-2.2l-.8-1.5 3.3-3.3z" /> <path fill="currentColor" d="M19.38 11.38a3 3 0 0 0 4.24 0l.03-.03a.5.5 0 0 0 0-.7L13.35.35a.5.5 0 0 0-.7 0l-.03.03a3 3 0 0 0 0 4.24L13 5l-2.92 2.92-3.65-.34a2 2 0 0 0-1.6.58l-.62.63a1 1 0 0 0 0 1.42l9.58 9.58a1 1 0 0 0 1.42 0l.63-.63a2 2 0 0 0 .58-1.6l-.34-3.64L19 11zM9.07 17.07a.5.5 0 0 1-.08.77l-5.15 3.43a.5.5 0 0 1-.63-.06l-.42-.42a.5.5 0 0 1-.06-.63L6.16 15a.5.5 0 0 1 .77-.08l2.14 2.14Z"/>
</svg> </svg>
</button> </button>
</Tooltip> </Tooltip>
@@ -76,9 +91,8 @@ const ChatHeader = ({
className={`chat-header-btn ${showMembers ? 'active' : ''}`} className={`chat-header-btn ${showMembers ? 'active' : ''}`}
onClick={onToggleMembers} onClick={onToggleMembers}
> >
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M14 8.006c0 2.206-1.346 4-3 4s-3-1.794-3-4 1.346-4 3-4 3 1.794 3 4zm-6.154 6.63c.896-.758 2.157-1.236 3.654-1.236s2.758.478 3.654 1.236c.898.76 1.346 1.773 1.346 2.87v.5h-10v-.5c0-1.097.448-2.11 1.346-2.87z" /> <path fill="currentColor" d="M14.5 8a3 3 0 1 0-2.7-4.3c-.2.4.06.86.44 1.12a5 5 0 0 1 2.14 3.08c.01.06.06.1.12.1ZM18.44 17.27c.15.43.54.73 1 .73h1.06c.83 0 1.5-.67 1.5-1.5a7.5 7.5 0 0 0-6.5-7.43c-.55-.08-.99.38-1.1.92-.06.3-.15.6-.26.87-.23.58-.05 1.3.47 1.63a9.53 9.53 0 0 1 3.83 4.78ZM12.5 9a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM2 20.5a7.5 7.5 0 0 1 15 0c0 .83-.67 1.5-1.5 1.5a.2.2 0 0 1-.2-.16c-.2-.96-.56-1.87-.88-2.54-.1-.23-.42-.15-.42.1v2.1a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-2.1c0-.25-.31-.33-.42-.1-.32.67-.67 1.58-.88 2.54a.2.2 0 0 1-.2.16A1.5 1.5 0 0 1 2 20.5Z"/>
<path d="M20 10.006c0 1.655-1.01 3-2.25 3s-2.25-1.345-2.25-3S16.51 7.006 17.75 7.006 20 8.351 20 10.006zm-1.146 5.282c.674-.57 1.622-.93 2.646-.93.906 0 1.754.282 2.417.781.663.5 1.083 1.178 1.083 1.867V17.5h-4.6c-.173-.652-.52-1.262-1.032-1.794a5.86 5.86 0 00-.514-.418z" />
</svg> </svg>
</button> </button>
</Tooltip> </Tooltip>

View File

@@ -33,7 +33,7 @@ const getUserColor = (username) => {
return colors[Math.abs(hash) % colors.length]; return colors[Math.abs(hash) % colors.length];
}; };
const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => { const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM, voiceStates }) => {
const [showUserPicker, setShowUserPicker] = useState(false); const [showUserPicker, setShowUserPicker] = useState(false);
const [allUsers, setAllUsers] = useState([]); const [allUsers, setAllUsers] = useState([]);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -217,6 +217,11 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
<div style={{ color: isActive ? 'var(--header-primary)' : 'var(--text-normal)', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', fontWeight: '500' }}> <div style={{ color: isActive ? 'var(--header-primary)' : 'var(--text-normal)', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', fontWeight: '500' }}>
{dm.other_username} {dm.other_username}
</div> </div>
{voiceStates && voiceStates[dm.channel_id]?.length > 0 && (
<div style={{ color: '#3ba55c', fontSize: '11px', fontWeight: '500' }}>
In Call
</div>
)}
</div> </div>
</div> </div>
<div className="dm-close-btn" onClick={(e) => handleCloseDM(e, dm)}> <div className="dm-close-btn" onClick={(e) => handleCloseDM(e, dm)}>

View File

@@ -214,18 +214,10 @@ const MessageItem = React.memo(({
const currentDate = new Date(msg.created_at); const currentDate = new Date(msg.created_at);
const userColor = getUserColor(msg.username || 'Unknown'); const userColor = getUserColor(msg.username || 'Unknown');
const systemMsg = parseSystemMessage(msg.content);
const renderMessageContent = () => { const renderMessageContent = () => {
const systemMsg = parseSystemMessage(msg.content); if (systemMsg) return null;
if (systemMsg) {
return (
<div className="system-message">
<svg width="16" height="16" viewBox="0 0 24 24" fill="#3ba55c" style={{ marginRight: '8px', flexShrink: 0 }}>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
<span style={{ fontStyle: 'italic', color: 'var(--header-secondary)' }}>{systemMsg.text || 'System event'}</span>
</div>
);
}
const attachmentMetadata = parseAttachment(msg.content); const attachmentMetadata = parseAttachment(msg.content);
if (attachmentMetadata) { if (attachmentMetadata) {
@@ -279,6 +271,20 @@ const MessageItem = React.memo(({
</div> </div>
)} )}
{showDateDivider && <div className="date-divider"><span>{dateLabel}</span></div>} {showDateDivider && <div className="date-divider"><span>{dateLabel}</span></div>}
{systemMsg ? (
<div id={`msg-${msg.id}`} className="system-message-row">
<svg width="18" height="18" viewBox="0 0 24 24" fill="#23a559" style={{ flexShrink: 0 }}>
<path d="M2 7.4A5.4 5.4 0 0 1 7.4 2c.36 0 .7.22.83.55l1.93 4.64a1 1 0 0 1-.43 1.25L7 10a8.52 8.52 0 0 0 7 7l1.12-2.24a1 1 0 0 1 1.19-.51l5.06 1.56c.38.11.63.46.63.85C22 19.6 19.6 22 16.66 22h-.37C8.39 22 2 15.6 2 7.71V7.4ZM13 3a1 1 0 0 1 1-1 8 8 0 0 1 8 8 1 1 0 1 1-2 0 6 6 0 0 0-6-6 1 1 0 0 1-1-1Z"/>
<path d="M13 7a1 1 0 0 1 1-1 4 4 0 0 1 4 4 1 1 0 1 1-2 0 2 2 0 0 0-2-2 1 1 0 0 1-1-1Z"/>
</svg>
<span className="system-message-text">
{systemMsg.text || 'System event'}
</span>
<span className="system-message-time">
{currentDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
</span>
</div>
) : (
<div <div
id={`msg-${msg.id}`} id={`msg-${msg.id}`}
className={`message-item${isGrouped ? ' message-grouped' : ''}`} className={`message-item${isGrouped ? ' message-grouped' : ''}`}
@@ -361,6 +367,7 @@ const MessageItem = React.memo(({
</div> </div>
</div> </div>
</div> </div>
)}
</React.Fragment> </React.Fragment>
); );
}, (prevProps, nextProps) => { }, (prevProps, nextProps) => {

View File

@@ -1045,6 +1045,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
activeDMChannel={activeDMChannel} activeDMChannel={activeDMChannel}
onSelectDM={(dm) => setActiveDMChannel(dm === 'friends' ? null : dm)} onSelectDM={(dm) => setActiveDMChannel(dm === 'friends' ? null : dm)}
onOpenDM={onOpenDM} onOpenDM={onOpenDM}
voiceStates={voiceStates}
/> />
</div> </div>
); );
@@ -1605,7 +1606,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
<ColoredIcon src={disconnectIcon} color="var(--header-secondary)" size="20px" /> <ColoredIcon src={disconnectIcon} color="var(--header-secondary)" size="20px" />
</button> </button>
</div> </div>
<div style={{ color: 'var(--text-normal)', fontSize: 12, marginBottom: 4 }}>{voiceChannelName} / {serverName}</div> <div style={{ color: 'var(--text-normal)', fontSize: 12, marginBottom: 4 }}>{dmChannels?.some(dm => dm.channel_id === voiceChannelId) ? `Call with ${voiceChannelName}` : `${voiceChannelName} / ${serverName}`}</div>
<div style={{ marginBottom: 8 }}><VoiceTimer /></div> <div style={{ marginBottom: 8 }}><VoiceTimer /></div>
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)} title="Turn On Camera" style={voicePanelButtonStyle}> <button onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)} title="Turn On Camera" style={voicePanelButtonStyle}>

View File

@@ -64,6 +64,7 @@ export const VoiceProvider = ({ children }) => {
parseInt(localStorage.getItem('voiceOutputVolume') || '100') parseInt(localStorage.getItem('voiceOutputVolume') || '100')
); );
const isMovingRef = useRef(false); const isMovingRef = useRef(false);
const isDMCallRef = useRef(false);
const [isReceivingScreenShareAudio, setIsReceivingScreenShareAudio] = useState(false); const [isReceivingScreenShareAudio, setIsReceivingScreenShareAudio] = useState(false);
const convex = useConvex(); const convex = useConvex();
@@ -226,8 +227,9 @@ export const VoiceProvider = ({ children }) => {
} }
} }
const connectToVoice = async (channelId, channelName, userId) => { const connectToVoice = async (channelId, channelName, userId, isDMCall = false) => {
if (activeChannelId === channelId) return; if (activeChannelId === channelId) return;
isDMCallRef.current = isDMCall;
if (room) await room.disconnect(); if (room) await room.disconnect();
@@ -406,6 +408,7 @@ export const VoiceProvider = ({ children }) => {
useEffect(() => { useEffect(() => {
if (!activeChannelId || !serverSettings?.afkChannelId || isInAfkChannel) return; if (!activeChannelId || !serverSettings?.afkChannelId || isInAfkChannel) return;
if (!idle?.getSystemIdleTime) return; if (!idle?.getSystemIdleTime) return;
if (isDMCallRef.current) return; // Skip AFK for DM calls
const afkTimeout = serverSettings.afkTimeout || 300; const afkTimeout = serverSettings.afkTimeout || 300;
const interval = setInterval(async () => { const interval = setInterval(async () => {
@@ -603,6 +606,7 @@ export const VoiceProvider = ({ children }) => {
const disconnectVoice = () => { const disconnectVoice = () => {
console.log('User manually disconnected voice'); console.log('User manually disconnected voice');
isDMCallRef.current = false;
if (room) room.disconnect(); if (room) room.disconnect();
}; };

View File

@@ -1888,10 +1888,23 @@ body {
/* ============================================ /* ============================================
SYSTEM MESSAGES SYSTEM MESSAGES
============================================ */ ============================================ */
.system-message { .system-message-row {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 2px 0; justify-content: center;
gap: 8px;
padding: 8px 16px;
}
.system-message-text {
color: var(--text-muted);
font-size: 14px;
}
.system-message-time {
color: var(--text-muted);
font-size: 12px;
opacity: 0.7;
} }
/* ============================================ /* ============================================
@@ -3692,3 +3705,80 @@ img.search-dropdown-avatar {
margin-right: 4px; margin-right: 4px;
text-transform: uppercase; text-transform: uppercase;
} }
/* DM Call Styles */
.dm-call-stage {
height: 45%;
min-height: 200px;
max-height: 80%;
display: flex;
flex-direction: column;
border-bottom: 2px solid var(--bg-tertiary);
overflow: hidden;
position: relative;
}
.dm-call-expand-btn {
position: absolute;
left: 8px;
top: 8px;
z-index: 5;
background: var(--bg-tertiary);
border: none;
border-radius: 4px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--interactive-normal);
opacity: 0.7;
transition: opacity 0.15s;
}
.dm-call-expand-btn:hover {
opacity: 1;
color: var(--interactive-hover);
}
.dm-call-resize-handle {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 6px;
cursor: ns-resize;
z-index: 5;
}
.dm-call-resize-handle:hover {
background: var(--interactive-normal);
opacity: 0.3;
}
.dm-call-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--bg-tertiary);
color: var(--text-normal);
font-size: 14px;
}
.dm-call-join-btn {
background-color: #3ba55c;
color: white;
border: none;
border-radius: 4px;
padding: 6px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.dm-call-join-btn:hover {
background-color: #2d7d46;
}

View File

@@ -49,7 +49,7 @@ const Chat = () => {
const convex = useConvex(); const convex = useConvex();
const { toasts, addToast, removeToast, ToastContainer } = useToasts(); const { toasts, addToast, removeToast, ToastContainer } = useToasts();
const prevDmChannelsRef = useRef(null); const prevDmChannelsRef = useRef(null);
const { toggleMute } = useVoice(); const { toggleMute, connectToVoice, disconnectVoice, activeChannelId: voiceActiveChannelId, voiceStates, room } = useVoice();
// Keyboard shortcuts // Keyboard shortcuts
useEffect(() => { useEffect(() => {
@@ -197,15 +197,93 @@ const Chat = () => {
}, []); }, []);
const activeChannelObj = channels.find(c => c._id === activeChannel); const activeChannelObj = channels.find(c => c._id === activeChannel);
const { room, voiceStates, activeChannelId: voiceActiveChannelId, watchingStreamOf } = useVoice(); const { watchingStreamOf } = useVoice();
const isDMView = view === 'me' && activeDMChannel; const isDMView = view === 'me' && activeDMChannel;
const isServerTextChannel = view === 'server' && activeChannel && activeChannelObj?.type !== 'voice'; const isServerTextChannel = view === 'server' && activeChannel && activeChannelObj?.type !== 'voice';
const currentChannelId = isDMView ? activeDMChannel.channel_id : activeChannel; const currentChannelId = isDMView ? activeDMChannel.channel_id : activeChannel;
const effectiveShowMembers = isMobile ? false : showMembers; const effectiveShowMembers = isMobile ? false : showMembers;
// DM call state
const dmCallActive = isDMView && activeDMChannel
? (voiceStates[activeDMChannel.channel_id]?.length > 0) : false;
const isInDMCall = isDMView && activeDMChannel
? voiceActiveChannelId === activeDMChannel.channel_id : false;
const [dmCallExpanded, setDmCallExpanded] = useState(false);
const [dmCallStageHeight, setDmCallStageHeight] = useState(45);
useEffect(() => {
if (!isInDMCall) setDmCallExpanded(false);
}, [isInDMCall]);
const handleDmCallResizeStart = useCallback((e) => {
e.preventDefault();
const chatContent = e.target.closest('.chat-content');
if (!chatContent) return;
const startY = e.clientY;
const contentRect = chatContent.getBoundingClientRect();
const startHeight = dmCallStageHeight;
const onMouseMove = (moveE) => {
const deltaY = moveE.clientY - startY;
const deltaPercent = (deltaY / contentRect.height) * 100;
const newHeight = Math.min(80, Math.max(20, startHeight + deltaPercent));
setDmCallStageHeight(newHeight);
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}, [dmCallStageHeight]);
const handleStartDMCall = useCallback(async () => {
if (!activeDMChannel) return;
const dmChannelId = activeDMChannel.channel_id;
const otherUsername = activeDMChannel.other_username;
// Already in this DM call
if (voiceActiveChannelId === dmChannelId) return;
// If in another voice channel, disconnect first
if (voiceActiveChannelId) {
disconnectVoice();
// Brief delay for cleanup
await new Promise(r => setTimeout(r, 300));
}
// Check if this is a new call (no one currently in it)
const isNewCall = !voiceStates[dmChannelId]?.length;
connectToVoice(dmChannelId, otherUsername, userId, true);
// Send system message for new calls
if (isNewCall && channelKeys[dmChannelId]) {
try {
const systemContent = JSON.stringify({ type: 'system', text: `${username} started a call` });
const { content: encryptedContent, iv, tag } = await crypto.encryptData(systemContent, channelKeys[dmChannelId]);
const ciphertext = encryptedContent + tag;
const signingKey = sessionStorage.getItem('signingKey');
if (signingKey) {
await convex.mutation(api.messages.send, {
channelId: dmChannelId,
senderId: userId,
ciphertext,
nonce: iv,
signature: await crypto.signMessage(signingKey, ciphertext),
keyVersion: 1
});
}
} catch (e) {
console.error('Failed to send call system message:', e);
}
}
}, [activeDMChannel, voiceActiveChannelId, disconnectVoice, connectToVoice, userId, username, channelKeys, crypto, convex, voiceStates]);
// PiP: show when watching a stream and NOT currently viewing the voice channel in VoiceStage // PiP: show when watching a stream and NOT currently viewing the voice channel in VoiceStage
const isViewingVoiceStage = view === 'server' && activeChannelObj?.type === 'voice' && activeChannel === voiceActiveChannelId; const isViewingDMCallStage = isDMView && isInDMCall;
const isViewingVoiceStage = (view === 'server' && activeChannelObj?.type === 'voice' && activeChannel === voiceActiveChannelId) || isViewingDMCallStage;
const showPiP = watchingStreamOf !== null && !isViewingVoiceStage; const showPiP = watchingStreamOf !== null && !isViewingVoiceStage;
const handleGoBackToStream = useCallback(() => { const handleGoBackToStream = useCallback(() => {
@@ -349,33 +427,61 @@ const Chat = () => {
onTogglePinned={() => setShowPinned(p => !p)} onTogglePinned={() => setShowPinned(p => !p)}
isMobile={isMobile} isMobile={isMobile}
onMobileBack={handleMobileBack} onMobileBack={handleMobileBack}
onStartCall={handleStartDMCall}
isDMCallActive={dmCallActive}
{...searchProps} {...searchProps}
/> />
<div className="chat-content"> <div className="chat-content" style={isInDMCall ? { flexDirection: 'column' } : undefined}>
<ChatArea {dmCallActive && !isInDMCall && (
channelId={activeDMChannel.channel_id} <div className="dm-call-banner">
channelName={activeDMChannel.other_username} <span>{activeDMChannel.other_username} is in a call</span>
channelType="dm" <button className="dm-call-join-btn" onClick={handleStartDMCall}>Join Call</button>
channelKey={channelKeys[activeDMChannel.channel_id]} </div>
username={username} )}
userId={userId} {isInDMCall && (
showMembers={false} <div className="dm-call-stage" style={dmCallExpanded ? { height: '100%', maxHeight: '100%' } : { height: `${dmCallStageHeight}%` }}>
onToggleMembers={() => {}} <button
onOpenDM={openDM} className="dm-call-expand-btn"
showPinned={showPinned} onClick={() => setDmCallExpanded(prev => !prev)}
onTogglePinned={() => setShowPinned(false)} title={dmCallExpanded ? "Show Chat" : "Hide Chat"}
/> >
<SearchPanel <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"
visible={showSearchResults} style={{ transform: dmCallExpanded ? 'rotate(180deg)' : undefined, transition: 'transform 0.2s' }}>
onClose={handleCloseSearchResults} <path d="M5.3 9.3a1 1 0 0 1 1.4 0l5.3 5.29 5.3-5.3a1 1 0 1 1 1.4 1.42l-6 6a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.42Z"/>
channels={channels} </svg>
isDM={true} </button>
dmChannelId={activeDMChannel.channel_id} <VoiceStage room={room} channelId={activeDMChannel.channel_id} voiceStates={voiceStates} channelName={activeDMChannel.other_username} />
onJumpToMessage={handleJumpToMessage} {!dmCallExpanded && <div className="dm-call-resize-handle" onMouseDown={handleDmCallResizeStart} />}
query={searchQuery} </div>
sortOrder={searchSortOrder} )}
onSortChange={setSearchSortOrder} {(!isInDMCall || !dmCallExpanded) && (
/> <div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
<ChatArea
channelId={activeDMChannel.channel_id}
channelName={activeDMChannel.other_username}
channelType="dm"
channelKey={channelKeys[activeDMChannel.channel_id]}
username={username}
userId={userId}
showMembers={false}
onToggleMembers={() => {}}
onOpenDM={openDM}
showPinned={showPinned}
onTogglePinned={() => setShowPinned(false)}
/>
<SearchPanel
visible={showSearchResults}
onClose={handleCloseSearchResults}
channels={channels}
isDM={true}
dmChannelId={activeDMChannel.channel_id}
onJumpToMessage={handleJumpToMessage}
query={searchQuery}
sortOrder={searchSortOrder}
onSortChange={setSearchSortOrder}
/>
</div>
)}
</div> </div>
</div> </div>
); );