diff --git a/TODO.md b/TODO.md index 6b1ce5a..c9838cf 100644 --- a/TODO.md +++ b/TODO.md @@ -8,4 +8,11 @@ - 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/ \ No newline at end of file +- Add photo / video albums like Commit https://commet.chat/ + + + + + When i switch from a private dm to the server i hear a ping notification even though i have no notifications. Also when i switch from a text channel to another text channel a certain text channel is the one thats triggering it and i dont know why. Its only for one user that its doing it. \ No newline at end of file diff --git a/packages/shared/src/assets/sounds/default_call_sound.mp3 b/packages/shared/src/assets/sounds/default_call_sound.mp3 new file mode 100644 index 0000000..0e04ded Binary files /dev/null and b/packages/shared/src/assets/sounds/default_call_sound.mp3 differ diff --git a/packages/shared/src/components/IncomingCallUI.jsx b/packages/shared/src/components/IncomingCallUI.jsx new file mode 100644 index 0000000..63ac5b5 --- /dev/null +++ b/packages/shared/src/components/IncomingCallUI.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import Avatar from './Avatar'; + +const IncomingCallUI = ({ callerUsername, callerAvatarUrl, onJoin, onReject }) => { + return ( +
+
+ +
+
{callerUsername}
+
Incoming call...
+
+ + +
+
+ ); +}; + +export default IncomingCallUI; diff --git a/packages/shared/src/components/Sidebar.jsx b/packages/shared/src/components/Sidebar.jsx index 4d06260..e1e2a5b 100644 --- a/packages/shared/src/components/Sidebar.jsx +++ b/packages/shared/src/components/Sidebar.jsx @@ -798,14 +798,17 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam ...channels.map(c => c._id), ...dmChannels.map(dm => dm.channel_id) ], [channels, dmChannels]); - const allReadStates = useQuery( + const rawAllReadStates = useQuery( api.readState.getAllReadStates, userId ? { userId } : "skip" - ) || []; - const latestTimestamps = useQuery( + ); + const rawLatestTimestamps = useQuery( api.readState.getLatestMessageTimestamps, channelIds.length > 0 ? { channelIds } : "skip" - ) || []; + ); + const allReadStates = rawAllReadStates || []; + const latestTimestamps = rawLatestTimestamps || []; + const unreadQueriesLoaded = rawAllReadStates !== undefined && rawLatestTimestamps !== undefined; const unreadChannels = React.useMemo(() => { const set = new Set(); @@ -835,8 +838,13 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam const prevUnreadDMsRef = useRef(null); useEffect(() => { + if (!unreadQueriesLoaded) return; + const currentIds = new Set( - dmChannels.filter(dm => unreadChannels.has(dm.channel_id)).map(dm => dm.channel_id) + dmChannels.filter(dm => + unreadChannels.has(dm.channel_id) && + !(view === 'me' && activeDMChannel?.channel_id === dm.channel_id) + ).map(dm => dm.channel_id) ); if (prevUnreadDMsRef.current === null) { @@ -856,7 +864,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam } prevUnreadDMsRef.current = currentIds; - }, [dmChannels, unreadChannels, isReceivingScreenShareAudio]); + }, [dmChannels, unreadChannels, unreadQueriesLoaded, view, activeDMChannel, isReceivingScreenShareAudio]); const onRenameChannel = () => {}; diff --git a/packages/shared/src/index.css b/packages/shared/src/index.css index 90dd76c..067f361 100644 --- a/packages/shared/src/index.css +++ b/packages/shared/src/index.css @@ -3757,14 +3757,29 @@ img.search-dropdown-avatar { opacity: 0.3; } -.dm-call-banner { +.dm-call-idle-stage { display: flex; + flex-direction: column; align-items: center; - justify-content: space-between; - padding: 8px 16px; - background-color: var(--bg-secondary); - border-bottom: 1px solid var(--bg-tertiary); + justify-content: center; + background-color: var(--bg-tertiary); + padding: 32px 16px; + gap: 8px; + min-height: 200px; + height: 45%; + max-height: 80%; + border-bottom: 2px solid var(--bg-tertiary); +} + +.dm-call-idle-username { color: var(--text-normal); + font-size: 18px; + font-weight: 600; + margin-top: 8px; +} + +.dm-call-idle-status { + color: var(--text-muted); font-size: 14px; } @@ -3781,4 +3796,88 @@ img.search-dropdown-avatar { .dm-call-join-btn:hover { background-color: #2d7d46; +} + +/* Incoming call UI */ +.incoming-call-ui { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: var(--bg-tertiary); + padding: 32px 16px; + gap: 12px; + flex: 1; + min-height: 260px; +} + +.incoming-call-avatar-ring { + position: relative; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 8px; +} + +.incoming-call-avatar-ring::before { + content: ''; + position: absolute; + width: 96px; + height: 96px; + border-radius: 50%; + border: 3px solid #3ba55c; + animation: call-ring-pulse 1.5s ease-out infinite; +} + +@keyframes call-ring-pulse { + 0% { + transform: scale(1); + opacity: 0.8; + } + 100% { + transform: scale(1.4); + opacity: 0; + } +} + +.incoming-call-username { + color: var(--text-normal); + font-size: 20px; + font-weight: 600; +} + +.incoming-call-subtitle { + color: var(--text-muted); + font-size: 14px; +} + +.incoming-call-buttons { + display: flex; + gap: 24px; + margin-top: 16px; +} + +.incoming-call-btn { + width: 56px; + height: 56px; + border-radius: 50%; + border: none; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: white; + transition: filter 0.15s; +} + +.incoming-call-btn:hover { + filter: brightness(1.15); +} + +.incoming-call-btn.join { + background-color: #3ba55c; +} + +.incoming-call-btn.reject { + background-color: #ed4245; } \ No newline at end of file diff --git a/packages/shared/src/pages/Chat.jsx b/packages/shared/src/pages/Chat.jsx index 4447a32..67e9b67 100644 --- a/packages/shared/src/pages/Chat.jsx +++ b/packages/shared/src/pages/Chat.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useQuery, useConvex } from 'convex/react'; import { api } from '../../../../convex/_generated/api'; import Sidebar from '../components/Sidebar'; @@ -16,6 +16,9 @@ import { PresenceProvider } from '../contexts/PresenceContext'; import { getUserPref, setUserPref } from '../utils/userPreferences'; import { usePlatform } from '../platform'; import { useIsMobile } from '../hooks/useIsMobile'; +import IncomingCallUI from '../components/IncomingCallUI'; +import Avatar from '../components/Avatar'; +import callRingSound from '../assets/sounds/default_call_sound.mp3'; const MAX_SEARCH_HISTORY = 10; @@ -211,11 +214,66 @@ const Chat = () => { ? voiceActiveChannelId === activeDMChannel.channel_id : false; const [dmCallExpanded, setDmCallExpanded] = useState(false); const [dmCallStageHeight, setDmCallStageHeight] = useState(45); + const [rejectedCallChannelId, setRejectedCallChannelId] = useState(null); + const ringAudioRef = useRef(null); + const ringTimeoutRef = useRef(null); useEffect(() => { if (!isInDMCall) setDmCallExpanded(false); }, [isInDMCall]); + // Global incoming DM call detection — not gated by current view + const incomingDMCall = useMemo(() => { + if (!userId) return null; + for (const dm of dmChannels) { + const users = voiceStates[dm.channel_id] || []; + if (users.length > 0 && voiceActiveChannelId !== dm.channel_id) { + const caller = users.find(u => u.userId !== userId); + if (caller) { + return { + channelId: dm.channel_id, + otherUsername: dm.other_username, + callerUsername: caller.username, + callerAvatarUrl: caller.avatarUrl || null, + }; + } + } + } + return null; + }, [dmChannels, voiceStates, voiceActiveChannelId, userId]); + + // Play ringing sound for incoming DM calls (global — works from any view) + useEffect(() => { + if (incomingDMCall && incomingDMCall.channelId !== rejectedCallChannelId) { + const audio = new Audio(callRingSound); + audio.loop = true; + audio.volume = 0.5; + audio.play().catch(() => {}); + ringAudioRef.current = audio; + + ringTimeoutRef.current = setTimeout(() => { + audio.pause(); + audio.currentTime = 0; + setRejectedCallChannelId(incomingDMCall.channelId); + }, 30000); + + return () => { + audio.pause(); + audio.currentTime = 0; + ringAudioRef.current = null; + clearTimeout(ringTimeoutRef.current); + ringTimeoutRef.current = null; + }; + } + }, [incomingDMCall?.channelId, rejectedCallChannelId]); + + // Clear rejected state when the rejected call's channel becomes empty + useEffect(() => { + if (rejectedCallChannelId && !(voiceStates[rejectedCallChannelId]?.length > 0)) { + setRejectedCallChannelId(null); + } + }, [rejectedCallChannelId, voiceStates]); + const handleDmCallResizeStart = useCallback((e) => { e.preventDefault(); const chatContent = e.target.closest('.chat-content'); @@ -246,6 +304,9 @@ const Chat = () => { // Already in this DM call if (voiceActiveChannelId === dmChannelId) return; + // Suppress ringing when we leave later — just show the banner + setRejectedCallChannelId(dmChannelId); + // If in another voice channel, disconnect first if (voiceActiveChannelId) { disconnectVoice(); @@ -417,6 +478,7 @@ const Chat = () => { function renderMainContent() { if (view === 'me') { if (activeDMChannel) { + const showIncomingUI = dmCallActive && !isInDMCall && incomingDMCall?.channelId === activeDMChannel?.channel_id && rejectedCallChannelId !== activeDMChannel?.channel_id; return (
{ isDMCallActive={dmCallActive} {...searchProps} /> -
- {dmCallActive && !isInDMCall && ( -
- {activeDMChannel.other_username} is in a call +
+ {showIncomingUI && ( + setRejectedCallChannelId(incomingDMCall.channelId)} + /> + )} + {dmCallActive && !isInDMCall && !showIncomingUI && ( +
+ +
{activeDMChannel.other_username}
+
In a call
)}