feat: introduce a comprehensive LiveKit-based voice and video chat system with state management and screen sharing capabilities.
All checks were successful
Build and Release / build-and-release (push) Successful in 10m6s

This commit is contained in:
Bryan1029384756
2026-02-12 18:49:31 -06:00
parent 751f428adc
commit 2201c56cb2
16 changed files with 791 additions and 270 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="./vite.svg" /> <link rel="icon" type="image/svg+xml" href="./vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>discord</title> <title>discord</title>
<script type="module" crossorigin src="./assets/index-BhwDWh5r.js"></script> <script type="module" crossorigin src="./assets/index-BeX08324.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-D8p__dJ4.css"> <link rel="stylesheet" crossorigin href="./assets/index-CFE9j0dq.css">
</head> </head>
<body> <body>
<script> <script>

View File

@@ -1,7 +1,7 @@
{ {
"name": "discord", "name": "discord",
"private": true, "private": true,
"version": "1.0.9", "version": "1.0.10",
"description": "A Discord clone built with Convex, React, and Electron", "description": "A Discord clone built with Convex, React, and Electron",
"author": "Moyettes", "author": "Moyettes",
"type": "module", "type": "module",

View File

@@ -118,7 +118,9 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
display: 'flex', allignItems: 'center', justifyContent: 'center' display: 'flex', allignItems: 'center', justifyContent: 'center'
}} }}
> >
<svg width="12" height="12" viewBox="0 0 12 12">
<polygon fill="currentColor" points="11,1.576 10.424,1 6,5.424 1.576,1 1,1.576 5.424,6 1,10.424 1.576,11 6,6.576 10.424,11 11,10.424 6.576,6" />
</svg>
</button> </button>
</div> </div>

View File

@@ -700,6 +700,25 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
onTogglePinned(); onTogglePinned();
}, [channelId, channelKey]); }, [channelId, channelKey]);
const myRoleNames = members?.find(m => m.username === username)?.roles?.map(r => r.name) || [];
const isMentionedInContent = useCallback((content) => {
if (!content) return false;
return content.includes(`@${username}`) ||
myRoleNames.some(rn =>
rn.startsWith('@') ? content.includes(rn) : content.includes(`@role:${rn}`)
);
}, [username, myRoleNames]);
const playPingSound = useCallback(() => {
const now = Date.now();
if (now - lastPingTimeRef.current < 1000) return;
lastPingTimeRef.current = now;
const audio = new Audio(PingSound);
audio.volume = 0.5;
audio.play().catch(() => {});
}, []);
// Play ping sound when a new message mentions us (by username or role) // Play ping sound when a new message mentions us (by username or role)
useEffect(() => { useEffect(() => {
if (!decryptedMessages.length) return; if (!decryptedMessages.length) return;
@@ -795,25 +814,6 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
...filteredMentionRoles.map(r => ({ type: 'role', ...r })), ...filteredMentionRoles.map(r => ({ type: 'role', ...r })),
...filteredMentionMembers.map(m => ({ type: 'member', ...m })), ...filteredMentionMembers.map(m => ({ type: 'member', ...m })),
]; ];
const myRoleNames = members?.find(m => m.username === username)?.roles?.map(r => r.name) || [];
const isMentionedInContent = useCallback((content) => {
if (!content) return false;
return content.includes(`@${username}`) ||
myRoleNames.some(rn =>
rn.startsWith('@') ? content.includes(rn) : content.includes(`@role:${rn}`)
);
}, [username, myRoleNames]);
const playPingSound = useCallback(() => {
const now = Date.now();
if (now - lastPingTimeRef.current < 1000) return;
lastPingTimeRef.current = now;
const audio = new Audio(PingSound);
audio.volume = 0.5;
audio.play().catch(() => {});
}, []);
const scrollToBottom = useCallback((force = false) => { const scrollToBottom = useCallback((force = false) => {
const container = messagesContainerRef.current; const container = messagesContainerRef.current;
if (!container) return; if (!container) return;

View File

@@ -0,0 +1,269 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useVoice } from '../contexts/VoiceContext';
import { VideoRenderer, useParticipantTrack } from '../utils/streamUtils.jsx';
import { getUserPref, setUserPref } from '../utils/userPreferences';
const MIN_WIDTH = 240;
const MIN_HEIGHT = 135;
const MAX_WIDTH_RATIO = 0.75;
const MAX_HEIGHT_RATIO = 0.75;
const DEFAULT_WIDTH = 320;
const DEFAULT_HEIGHT = 180;
const ASPECT_RATIO = 16 / 9;
const LIVE_BADGE_STYLE = {
backgroundColor: '#ed4245', borderRadius: '4px', padding: '2px 6px',
color: 'white', fontSize: '10px', fontWeight: 'bold',
textTransform: 'uppercase', letterSpacing: '0.5px',
};
const FloatingStreamPiP = ({ onGoBackToStream }) => {
const { room, watchingStreamOf, setWatchingStreamOf, voiceStates, activeChannelId } = useVoice();
const pipUserId = localStorage.getItem('userId');
const [position, setPosition] = useState(() => getUserPref(pipUserId, 'pipPosition', { x: -1, y: -1 }));
const [size, setSize] = useState(() => getUserPref(pipUserId, 'pipSize', { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }));
const [hovering, setHovering] = useState(false);
const isDragging = useRef(false);
const isResizing = useRef(false);
const dragOffset = useRef({ x: 0, y: 0 });
const resizeStart = useRef({ x: 0, y: 0, width: 0, height: 0 });
const containerRef = useRef(null);
// Initialize position to bottom-right on mount (only if no saved position)
useEffect(() => {
if (position.x === -1 && position.y === -1) {
setPosition({
x: window.innerWidth - size.width - 24,
y: window.innerHeight - size.height - 24,
});
}
}, []);
// Find the watched participant from the room
const participant = (() => {
if (!room || !watchingStreamOf) return null;
if (room.localParticipant.identity === watchingStreamOf) return room.localParticipant;
return room.remoteParticipants.get(watchingStreamOf) || null;
})();
const screenTrack = useParticipantTrack(participant, 'screenshare');
// Resolve streamer username from voiceStates
const streamerUsername = (() => {
if (!watchingStreamOf) return '';
for (const users of Object.values(voiceStates)) {
const u = users.find(u => u.userId === watchingStreamOf);
if (u) return u.username;
}
return watchingStreamOf;
})();
// Drag handlers
const handleDragStart = useCallback((e) => {
if (isResizing.current) return;
e.preventDefault();
isDragging.current = true;
dragOffset.current = {
x: e.clientX - position.x,
y: e.clientY - position.y,
};
}, [position]);
useEffect(() => {
const handleMouseMove = (e) => {
if (isDragging.current) {
let newX = e.clientX - dragOffset.current.x;
let newY = e.clientY - dragOffset.current.y;
// Constrain to window bounds
newX = Math.max(0, Math.min(newX, window.innerWidth - size.width));
newY = Math.max(0, Math.min(newY, window.innerHeight - size.height));
setPosition({ x: newX, y: newY });
}
if (isResizing.current) {
const dx = e.clientX - resizeStart.current.x;
const dy = e.clientY - resizeStart.current.y;
// Use the larger delta to maintain aspect ratio
const maxW = window.innerWidth * MAX_WIDTH_RATIO;
const maxH = window.innerHeight * MAX_HEIGHT_RATIO;
let newWidth = resizeStart.current.width + dx;
newWidth = Math.max(MIN_WIDTH, Math.min(maxW, newWidth));
let newHeight = newWidth / ASPECT_RATIO;
if (newHeight > maxH) {
newHeight = maxH;
newWidth = newHeight * ASPECT_RATIO;
}
if (newHeight < MIN_HEIGHT) {
newHeight = MIN_HEIGHT;
newWidth = newHeight * ASPECT_RATIO;
}
setSize({ width: Math.round(newWidth), height: Math.round(newHeight) });
}
};
const handleMouseUp = () => {
isDragging.current = false;
isResizing.current = false;
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [size]);
// Resize handler
const handleResizeStart = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
isResizing.current = true;
resizeStart.current = {
x: e.clientX,
y: e.clientY,
width: size.width,
height: size.height,
};
}, [size]);
// Debounced persist of PiP position and size
useEffect(() => {
if (position.x === -1 && position.y === -1) return;
const timer = setTimeout(() => {
setUserPref(pipUserId, 'pipPosition', position);
setUserPref(pipUserId, 'pipSize', size);
}, 300);
return () => clearTimeout(timer);
}, [position, size, pipUserId]);
const handleStopWatching = useCallback(() => {
setWatchingStreamOf(null);
}, [setWatchingStreamOf]);
if (!watchingStreamOf || !participant) return null;
return (
<div
ref={containerRef}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
style={{
position: 'fixed',
left: position.x,
top: position.y,
width: size.width,
height: size.height,
zIndex: 1000,
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
backgroundColor: 'black',
cursor: isDragging.current ? 'grabbing' : 'default',
userSelect: 'none',
}}
onMouseDown={handleDragStart}
>
{/* Video content */}
{screenTrack ? (
<VideoRenderer track={screenTrack} style={{ objectFit: 'contain', pointerEvents: 'none' }} />
) : (
<div style={{
width: '100%', height: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#72767d', fontSize: '13px',
}}>
Loading stream...
</div>
)}
{/* Hover overlay */}
{hovering && (
<div style={{
position: 'absolute', inset: 0,
backgroundColor: 'rgba(0,0,0,0.55)',
display: 'flex', flexDirection: 'column',
justifyContent: 'space-between',
padding: '8px',
transition: 'opacity 0.15s',
}}>
{/* Top row: streamer name + back button */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ color: 'white', fontSize: '12px', fontWeight: '600' }}>
{streamerUsername}
</span>
<span style={LIVE_BADGE_STYLE}>LIVE</span>
</div>
<button
onClick={(e) => { e.stopPropagation(); onGoBackToStream(); }}
title="Back to Stream"
style={{
width: '28px', height: '28px', borderRadius: '4px',
backgroundColor: 'rgba(255,255,255,0.15)', border: 'none',
color: 'white', fontSize: '16px', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.25)'}
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.15)'}
>
{/* Back arrow icon */}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M6.5 12.5L2 8L6.5 3.5M2.5 8H14" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
{/* Bottom row: stop watching */}
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button
onClick={(e) => { e.stopPropagation(); handleStopWatching(); }}
style={{
backgroundColor: 'rgba(0,0,0,0.6)', color: 'white', border: 'none',
padding: '6px 14px', borderRadius: '4px', fontWeight: '600',
fontSize: '12px', cursor: 'pointer',
transition: 'background-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.8)'}
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.6)'}
>
Stop Watching
</button>
</div>
</div>
)}
{/* Resize handle (bottom-right corner) */}
<div
onMouseDown={handleResizeStart}
style={{
position: 'absolute',
bottom: 0,
right: 0,
width: '16px',
height: '16px',
cursor: 'nwse-resize',
zIndex: 2,
}}
>
{/* Diagonal grip lines */}
<svg width="16" height="16" viewBox="0 0 16 16" style={{ opacity: hovering ? 0.6 : 0 , transition: 'opacity 0.15s' }}>
<line x1="14" y1="4" x2="4" y2="14" stroke="white" strokeWidth="1.5"/>
<line x1="14" y1="8" x2="8" y2="14" stroke="white" strokeWidth="1.5"/>
<line x1="14" y1="12" x2="12" y2="14" stroke="white" strokeWidth="1.5"/>
</svg>
</div>
</div>
);
};
export default FloatingStreamPiP;

View File

@@ -30,6 +30,7 @@ import categoryCollapsedIcon from '../assets/icons/category_collapsed_icon.svg';
import PingSound from '../assets/sounds/ping.mp3'; import PingSound from '../assets/sounds/ping.mp3';
import screenShareStartSound from '../assets/sounds/screenshare_start.mp3'; import screenShareStartSound from '../assets/sounds/screenshare_start.mp3';
import screenShareStopSound from '../assets/sounds/screenshare_stop.mp3'; import screenShareStopSound from '../assets/sounds/screenshare_stop.mp3';
import { getUserPref, setUserPref } from '../utils/userPreferences';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245']; const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
@@ -376,7 +377,7 @@ function getScreenCaptureConstraints(selection) {
}; };
} }
const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMute, isServerMuted, hasPermission, onMessage }) => { const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMute, isServerMuted, hasPermission, onMessage, isSelf, userVolume, onVolumeChange }) => {
const menuRef = useRef(null); const menuRef = useRef(null);
const [pos, setPos] = useState({ top: y, left: x }); const [pos, setPos] = useState({ top: y, left: x });
@@ -397,8 +398,30 @@ const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMu
setPos({ top: newTop, left: newLeft }); setPos({ top: newTop, left: newLeft });
}, [x, y]); }, [x, y]);
const sliderPercent = (userVolume / 200) * 100;
return ( return (
<div ref={menuRef} className="context-menu" style={{ top: pos.top, left: pos.left }} onClick={(e) => e.stopPropagation()}> <div ref={menuRef} className="context-menu" style={{ top: pos.top, left: pos.left }} onClick={(e) => e.stopPropagation()}>
{!isSelf && (
<>
<div className="context-menu-volume" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
<div className="context-menu-volume-label">
<span>User Volume</span>
<span>{userVolume}%</span>
</div>
<input
type="range"
min="0"
max="200"
value={userVolume}
onChange={(e) => onVolumeChange(Number(e.target.value))}
className="context-menu-volume-slider"
style={{ background: `linear-gradient(to right, hsl(235 86% 65%) ${sliderPercent}%, var(--bg-tertiary) ${sliderPercent}%)` }}
/>
</div>
<div className="context-menu-separator" />
</>
)}
<div <div
className="context-menu-item context-menu-checkbox-item" className="context-menu-item context-menu-checkbox-item"
role="menuitemcheckbox" role="menuitemcheckbox"
@@ -726,7 +749,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
const [newChannelType, setNewChannelType] = useState('text'); const [newChannelType, setNewChannelType] = useState('text');
const [editingChannel, setEditingChannel] = useState(null); const [editingChannel, setEditingChannel] = useState(null);
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false); const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
const [collapsedCategories, setCollapsedCategories] = useState({}); const [collapsedCategories, setCollapsedCategories] = useState(() => getUserPref(userId, 'collapsedCategories', {}));
const [channelListContextMenu, setChannelListContextMenu] = useState(null); const [channelListContextMenu, setChannelListContextMenu] = useState(null);
const [voiceUserMenu, setVoiceUserMenu] = useState(null); const [voiceUserMenu, setVoiceUserMenu] = useState(null);
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false); const [showCreateChannelModal, setShowCreateChannelModal] = useState(false);
@@ -815,7 +838,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
if (activeChannel === id) onSelectChannel(null); if (activeChannel === id) onSelectChannel(null);
}; };
const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing, isPersonallyMuted, togglePersonalMute, isMuted: selfMuted, toggleMute, serverMute, isServerMuted, serverSettings } = useVoice(); const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing, isPersonallyMuted, togglePersonalMute, isMuted: selfMuted, toggleMute, serverMute, isServerMuted, serverSettings, getUserVolume, setUserVolume } = useVoice();
const handleStartCreate = () => { const handleStartCreate = () => {
setIsCreating(true); setIsCreating(true);
@@ -1084,7 +1107,11 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
}; };
const toggleCategory = (cat) => { const toggleCategory = (cat) => {
setCollapsedCategories(prev => ({ ...prev, [cat]: !prev[cat] })); setCollapsedCategories(prev => {
const next = { ...prev, [cat]: !prev[cat] };
setUserPref(userId, 'collapsedCategories', next);
return next;
});
}; };
// Group channels by categoryId // Group channels by categoryId
@@ -1590,6 +1617,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
y={voiceUserMenu.y} y={voiceUserMenu.y}
user={voiceUserMenu.user} user={voiceUserMenu.user}
onClose={() => setVoiceUserMenu(null)} onClose={() => setVoiceUserMenu(null)}
isSelf={voiceUserMenu.user.userId === userId}
isMuted={voiceUserMenu.user.userId === userId ? selfMuted : isPersonallyMuted(voiceUserMenu.user.userId)} isMuted={voiceUserMenu.user.userId === userId ? selfMuted : isPersonallyMuted(voiceUserMenu.user.userId)}
onMute={() => voiceUserMenu.user.userId === userId ? toggleMute() : togglePersonalMute(voiceUserMenu.user.userId)} onMute={() => voiceUserMenu.user.userId === userId ? toggleMute() : togglePersonalMute(voiceUserMenu.user.userId)}
isServerMuted={isServerMuted(voiceUserMenu.user.userId)} isServerMuted={isServerMuted(voiceUserMenu.user.userId)}
@@ -1599,6 +1627,8 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
onOpenDM(voiceUserMenu.user.userId, voiceUserMenu.user.username); onOpenDM(voiceUserMenu.user.userId, voiceUserMenu.user.username);
onViewChange('me'); onViewChange('me');
}} }}
userVolume={getUserVolume(voiceUserMenu.user.userId)}
onVolumeChange={(vol) => setUserVolume(voiceUserMenu.user.userId, vol)}
/> />
)} )}
{showCreateChannelModal && ( {showCreateChannelModal && (

View File

@@ -3,6 +3,7 @@ import { Track, RoomEvent } from 'livekit-client';
import { useVoice } from '../contexts/VoiceContext'; import { useVoice } from '../contexts/VoiceContext';
import ScreenShareModal from './ScreenShareModal'; import ScreenShareModal from './ScreenShareModal';
import Avatar from './Avatar'; import Avatar from './Avatar';
import { VideoRenderer, findTrackPubs, useParticipantTrack } from '../utils/streamUtils.jsx';
// Icons // Icons
import muteIcon from '../assets/icons/mute.svg'; import muteIcon from '../assets/icons/mute.svg';
@@ -66,124 +67,6 @@ const ColoredIcon = ({ src, color, size = '24px' }) => (
</div> </div>
); );
const VideoRenderer = ({ track, style }) => {
const videoRef = useRef(null);
useEffect(() => {
const el = videoRef.current;
if (el && track) {
track.attach(el);
return () => {
track.detach(el);
};
}
}, [track]);
return (
<video
ref={videoRef}
style={{ width: '100%', height: '100%', objectFit: 'contain', backgroundColor: 'black', ...style }}
/>
);
};
// --- Track Discovery Helpers ---
function findTrackPubs(participant) {
let cameraPub = null;
let screenSharePub = null;
let screenShareAudioPub = null;
const trackMap = participant.tracks || participant.trackPublications;
if (trackMap) {
for (const pub of trackMap.values()) {
const source = pub.source ? pub.source.toString().toLowerCase() : '';
const name = pub.trackName ? pub.trackName.toLowerCase() : '';
if (source === 'screen_share_audio' || name === 'screen_share_audio') {
screenShareAudioPub = pub;
} else if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) {
screenSharePub = pub;
} else if (source === 'camera' || name.includes('camera')) {
cameraPub = pub;
} else if (pub.kind === 'video' && !screenSharePub) {
screenSharePub = pub;
}
}
}
// Fallback for older SDKs
if ((!screenSharePub && !cameraPub) && participant.getTracks) {
try {
for (const pub of participant.getTracks()) {
const source = pub.source ? pub.source.toString().toLowerCase() : '';
const name = pub.trackName ? pub.trackName.toLowerCase() : '';
if (source === 'screen_share_audio' || name === 'screen_share_audio') {
screenShareAudioPub = pub;
} else if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) {
screenSharePub = pub;
} else if (source === 'camera' || name.includes('camera')) {
cameraPub = pub;
} else if (pub.kind === 'video' && !screenSharePub) {
screenSharePub = pub;
}
}
} catch (e) { /* ignore */ }
}
return { cameraPub, screenSharePub, screenShareAudioPub };
}
function useParticipantTrack(participant, source) {
const [track, setTrack] = useState(null);
useEffect(() => {
if (!participant) { setTrack(null); return; }
const resolve = () => {
const { cameraPub, screenSharePub } = findTrackPubs(participant);
const pub = source === 'camera' ? cameraPub : screenSharePub;
if (pub && !pub.isSubscribed && source === 'camera') {
pub.setSubscribed(true);
}
setTrack(pub?.track || null);
};
resolve();
const interval = setInterval(resolve, 1000);
const timeout = setTimeout(() => clearInterval(interval), 10000);
const onPub = () => resolve();
const onUnpub = () => resolve();
participant.on(RoomEvent.TrackPublished, onPub);
participant.on(RoomEvent.TrackUnpublished, onUnpub);
participant.on(RoomEvent.TrackSubscribed, onPub);
participant.on(RoomEvent.TrackUnsubscribed, onUnpub);
participant.on(RoomEvent.TrackMuted, onPub);
participant.on(RoomEvent.TrackUnmuted, onPub);
participant.on('localTrackPublished', onPub);
participant.on('localTrackUnpublished', onUnpub);
return () => {
clearInterval(interval);
clearTimeout(timeout);
participant.off(RoomEvent.TrackPublished, onPub);
participant.off(RoomEvent.TrackUnpublished, onUnpub);
participant.off(RoomEvent.TrackSubscribed, onPub);
participant.off(RoomEvent.TrackUnsubscribed, onUnpub);
participant.off(RoomEvent.TrackMuted, onPub);
participant.off(RoomEvent.TrackUnmuted, onPub);
participant.off('localTrackPublished', onPub);
participant.off('localTrackUnpublished', onUnpub);
};
}, [participant, source]);
return track;
}
// --- Components --- // --- Components ---
const ParticipantTile = ({ participant, username, avatarUrl }) => { const ParticipantTile = ({ participant, username, avatarUrl }) => {
@@ -590,13 +473,11 @@ const FocusedStreamView = ({
const VoiceStage = ({ room, channelId, voiceStates, channelName }) => { const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
const [participants, setParticipants] = useState([]); const [participants, setParticipants] = useState([]);
const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice } = useVoice(); const { isMuted, toggleMute, disconnectVoice, setScreenSharing, connectToVoice, watchingStreamOf, setWatchingStreamOf } = useVoice();
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false); const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
const [isScreenShareActive, setIsScreenShareActive] = useState(false); const [isScreenShareActive, setIsScreenShareActive] = useState(false);
const screenShareAudioTrackRef = useRef(null); const screenShareAudioTrackRef = useRef(null);
// Stream viewing state
const [watchingStreamOf, setWatchingStreamOf] = useState(null);
const [participantsCollapsed, setParticipantsCollapsed] = useState(false); const [participantsCollapsed, setParticipantsCollapsed] = useState(false);
useEffect(() => { useEffect(() => {
@@ -629,66 +510,19 @@ const VoiceStage = ({ room, channelId, voiceStates, channelName }) => {
}; };
}, [room]); }, [room]);
// Reset watching state when room disconnects // Reset collapsed state when room disconnects
useEffect(() => { useEffect(() => {
if (!room) { if (!room) {
setWatchingStreamOf(null);
setParticipantsCollapsed(false); setParticipantsCollapsed(false);
} }
}, [room]); }, [room]);
// Manage screen share subscriptions — only subscribe when actively watching
useEffect(() => {
if (!room) return;
const manageSubscriptions = () => {
for (const p of room.remoteParticipants.values()) {
const { screenSharePub, screenShareAudioPub } = findTrackPubs(p);
const shouldSubscribe = watchingStreamOf === p.identity;
if (screenSharePub && screenSharePub.isSubscribed !== shouldSubscribe) {
screenSharePub.setSubscribed(shouldSubscribe);
}
if (screenShareAudioPub && screenShareAudioPub.isSubscribed !== shouldSubscribe) {
screenShareAudioPub.setSubscribed(shouldSubscribe);
}
}
};
manageSubscriptions();
// Re-run when new tracks are published (autoSubscribe will subscribe them,
// so we need to immediately unsubscribe if not watching)
const onTrackChange = () => manageSubscriptions();
room.on(RoomEvent.TrackPublished, onTrackChange);
room.on(RoomEvent.TrackSubscribed, onTrackChange);
return () => {
room.off(RoomEvent.TrackPublished, onTrackChange);
room.off(RoomEvent.TrackSubscribed, onTrackChange);
};
}, [room, watchingStreamOf]);
// Derive streaming identities from voiceStates // Derive streaming identities from voiceStates
const voiceUsers = voiceStates?.[channelId] || []; const voiceUsers = voiceStates?.[channelId] || [];
const streamingIdentities = new Set( const streamingIdentities = new Set(
voiceUsers.filter(u => u.isScreenSharing).map(u => u.userId) voiceUsers.filter(u => u.isScreenSharing).map(u => u.userId)
); );
// Auto-exit if watched participant stops streaming or disconnects
useEffect(() => {
if (watchingStreamOf === null) return;
const watchedStillStreaming = streamingIdentities.has(watchingStreamOf);
const watchedStillConnected = participants.some(p => p.identity === watchingStreamOf);
if (!watchedStillStreaming || !watchedStillConnected) {
setWatchingStreamOf(null);
setParticipantsCollapsed(false);
}
}, [watchingStreamOf, streamingIdentities, participants]);
const handleStopWatching = useCallback(() => { const handleStopWatching = useCallback(() => {
setWatchingStreamOf(null); setWatchingStreamOf(null);
setParticipantsCollapsed(false); setParticipantsCollapsed(false);

View File

@@ -1,8 +1,9 @@
import React, { createContext, useContext, useState, useEffect, useRef } from 'react'; import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
import { Room, RoomEvent } from 'livekit-client'; import { Room, RoomEvent } from 'livekit-client';
import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react'; import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react';
import { useQuery, useConvex } from 'convex/react'; import { useQuery, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api'; import { api } from '../../../../convex/_generated/api';
import { findTrackPubs } from '../utils/streamUtils.jsx';
import '@livekit/components-styles'; import '@livekit/components-styles';
import joinSound from '../assets/sounds/join_call.mp3'; import joinSound from '../assets/sounds/join_call.mp3';
@@ -45,20 +46,81 @@ export const VoiceProvider = ({ children }) => {
const [isScreenSharing, setIsScreenSharingLocal] = useState(false); const [isScreenSharing, setIsScreenSharingLocal] = useState(false);
const isMovingRef = useRef(false); const isMovingRef = useRef(false);
// Stream watching state (lifted from VoiceStage so PiP can persist across navigation)
const [watchingStreamOf, setWatchingStreamOfRaw] = useState(null);
const setWatchingStreamOf = useCallback((identity) => {
setWatchingStreamOfRaw(identity);
}, []);
// Personal mute state (persisted to localStorage) // Personal mute state (persisted to localStorage)
const [personallyMutedUsers, setPersonallyMutedUsers] = useState(() => { const [personallyMutedUsers, setPersonallyMutedUsers] = useState(() => {
const saved = localStorage.getItem('personallyMutedUsers'); const saved = localStorage.getItem('personallyMutedUsers');
return new Set(saved ? JSON.parse(saved) : []); return new Set(saved ? JSON.parse(saved) : []);
}); });
// Per-user volume state: userId → 0-200 (persisted to localStorage)
const [userVolumes, setUserVolumes] = useState(() => {
const saved = localStorage.getItem('userVolumes');
return saved ? JSON.parse(saved) : {};
});
const setUserVolume = useCallback((userId, volume) => {
setUserVolumes(prev => {
const next = { ...prev, [userId]: volume };
localStorage.setItem('userVolumes', JSON.stringify(next));
return next;
});
// Apply volume to LiveKit participant
const participant = room?.remoteParticipants?.get(userId);
if (participant) participant.setVolume(volume / 100);
// Sync personal mute state
if (volume === 0) {
setPersonallyMutedUsers(prev => {
const next = new Set(prev);
next.add(userId);
localStorage.setItem('personallyMutedUsers', JSON.stringify([...next]));
return next;
});
} else {
setPersonallyMutedUsers(prev => {
if (!prev.has(userId)) return prev;
const next = new Set(prev);
next.delete(userId);
localStorage.setItem('personallyMutedUsers', JSON.stringify([...next]));
return next;
});
}
}, [room]);
const getUserVolume = useCallback((userId) => {
return userVolumes[userId] ?? 100;
}, [userVolumes]);
const togglePersonalMute = (userId) => { const togglePersonalMute = (userId) => {
setPersonallyMutedUsers(prev => { setPersonallyMutedUsers(prev => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(userId)) next.delete(userId); if (next.has(userId)) {
else next.add(userId); next.delete(userId);
localStorage.setItem('personallyMutedUsers', JSON.stringify([...next])); // Restore to stored volume (default 100)
const vol = userVolumes[userId] ?? 100;
const restoreVol = vol === 0 ? 100 : vol;
const participant = room?.remoteParticipants?.get(userId); const participant = room?.remoteParticipants?.get(userId);
if (participant) participant.setVolume(next.has(userId) ? 0 : 1); if (participant) participant.setVolume(restoreVol / 100);
// Update stored volume if it was 0
if (vol === 0) {
setUserVolumes(p => {
const n = { ...p, [userId]: 100 };
localStorage.setItem('userVolumes', JSON.stringify(n));
return n;
});
}
} else {
next.add(userId);
const participant = room?.remoteParticipants?.get(userId);
if (participant) participant.setVolume(0);
}
localStorage.setItem('personallyMutedUsers', JSON.stringify([...next]));
return next; return next;
}); });
}; };
@@ -241,18 +303,22 @@ export const VoiceProvider = ({ children }) => {
} }
}, [voiceStates, room]); }, [voiceStates, room]);
// Re-apply personal mutes when room or participants change // Re-apply personal mutes/volumes when room or participants change
useEffect(() => { useEffect(() => {
if (!room) return; if (!room) return;
const applyMutes = () => { const applyVolumes = () => {
for (const [identity, participant] of room.remoteParticipants) { for (const [identity, participant] of room.remoteParticipants) {
participant.setVolume(personallyMutedUsers.has(identity) ? 0 : 1); if (personallyMutedUsers.has(identity)) {
participant.setVolume(0);
} else {
participant.setVolume((userVolumes[identity] ?? 100) / 100);
}
} }
}; };
applyMutes(); applyVolumes();
room.on(RoomEvent.ParticipantConnected, applyMutes); room.on(RoomEvent.ParticipantConnected, applyVolumes);
return () => room.off(RoomEvent.ParticipantConnected, applyMutes); return () => room.off(RoomEvent.ParticipantConnected, applyVolumes);
}, [room, personallyMutedUsers]); }, [room, personallyMutedUsers, userVolumes]);
// AFK idle polling: move user to AFK channel when idle exceeds timeout // AFK idle polling: move user to AFK channel when idle exceeds timeout
useEffect(() => { useEffect(() => {
@@ -282,6 +348,75 @@ export const VoiceProvider = ({ children }) => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [activeChannelId, serverSettings?.afkChannelId, serverSettings?.afkTimeout, isInAfkChannel]); }, [activeChannelId, serverSettings?.afkChannelId, serverSettings?.afkTimeout, isInAfkChannel]);
// Manage screen share subscriptions — only subscribe when actively watching
useEffect(() => {
if (!room) return;
const manageSubscriptions = () => {
for (const p of room.remoteParticipants.values()) {
const { screenSharePub, screenShareAudioPub } = findTrackPubs(p);
const shouldSubscribe = watchingStreamOf === p.identity;
if (screenSharePub && screenSharePub.isSubscribed !== shouldSubscribe) {
screenSharePub.setSubscribed(shouldSubscribe);
}
if (screenShareAudioPub && screenShareAudioPub.isSubscribed !== shouldSubscribe) {
screenShareAudioPub.setSubscribed(shouldSubscribe);
}
}
};
manageSubscriptions();
const onTrackChange = () => manageSubscriptions();
room.on(RoomEvent.TrackPublished, onTrackChange);
room.on(RoomEvent.TrackSubscribed, onTrackChange);
return () => {
room.off(RoomEvent.TrackPublished, onTrackChange);
room.off(RoomEvent.TrackSubscribed, onTrackChange);
};
}, [room, watchingStreamOf]);
// Auto-exit if watched participant stops streaming or disconnects
useEffect(() => {
if (watchingStreamOf === null || !room) return;
const checkWatched = () => {
// Check if participant is still connected
const participant = room.remoteParticipants.get(watchingStreamOf)
|| (room.localParticipant.identity === watchingStreamOf ? room.localParticipant : null);
if (!participant) {
setWatchingStreamOfRaw(null);
return;
}
// Check if they're still screen sharing
const { screenSharePub } = findTrackPubs(participant);
if (!screenSharePub) {
setWatchingStreamOfRaw(null);
}
};
// Also listen for voiceStates changes — covered by the dependency array re-run
room.on(RoomEvent.ParticipantDisconnected, checkWatched);
room.on(RoomEvent.TrackUnpublished, checkWatched);
return () => {
room.off(RoomEvent.ParticipantDisconnected, checkWatched);
room.off(RoomEvent.TrackUnpublished, checkWatched);
};
}, [room, watchingStreamOf]);
// Reset watching state when room disconnects
useEffect(() => {
if (!room) {
setWatchingStreamOfRaw(null);
}
}, [room]);
const disconnectVoice = () => { const disconnectVoice = () => {
console.log('User manually disconnected voice'); console.log('User manually disconnected voice');
if (room) room.disconnect(); if (room) room.disconnect();
@@ -336,10 +471,15 @@ export const VoiceProvider = ({ children }) => {
personallyMutedUsers, personallyMutedUsers,
togglePersonalMute, togglePersonalMute,
isPersonallyMuted, isPersonallyMuted,
userVolumes,
setUserVolume,
getUserVolume,
serverMute, serverMute,
isServerMuted, isServerMuted,
isInAfkChannel, isInAfkChannel,
serverSettings serverSettings,
watchingStreamOf,
setWatchingStreamOf,
}}> }}>
{children} {children}
{room && ( {room && (

View File

@@ -1265,6 +1265,62 @@ body {
margin: 4px 0; margin: 4px 0;
} }
/* ============================================
CONTEXT MENU VOLUME SLIDER
============================================ */
.context-menu-volume {
padding: 8px 12px;
}
.context-menu-volume-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-weight: 600;
color: var(--header-secondary);
text-transform: uppercase;
margin-bottom: 8px;
}
.context-menu-volume-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
outline: none;
cursor: pointer;
}
.context-menu-volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: white;
cursor: pointer;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.context-menu-volume-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: white;
cursor: pointer;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.context-menu-volume-slider::-moz-range-track {
height: 4px;
border-radius: 2px;
background: transparent;
}
/* ============================================ /* ============================================
MENTION MENU MENTION MENU
============================================ */ ============================================ */

View File

@@ -4,12 +4,14 @@ import { api } from '../../../../convex/_generated/api';
import Sidebar from '../components/Sidebar'; import Sidebar from '../components/Sidebar';
import ChatArea from '../components/ChatArea'; import ChatArea from '../components/ChatArea';
import VoiceStage from '../components/VoiceStage'; import VoiceStage from '../components/VoiceStage';
import FloatingStreamPiP from '../components/FloatingStreamPiP';
import { useVoice } from '../contexts/VoiceContext'; import { useVoice } from '../contexts/VoiceContext';
import FriendsView from '../components/FriendsView'; import FriendsView from '../components/FriendsView';
import MembersList from '../components/MembersList'; import MembersList from '../components/MembersList';
import ChatHeader from '../components/ChatHeader'; import ChatHeader from '../components/ChatHeader';
import { useToasts } from '../components/Toast'; import { useToasts } from '../components/Toast';
import { PresenceProvider } from '../contexts/PresenceContext'; import { PresenceProvider } from '../contexts/PresenceContext';
import { getUserPref, setUserPref } from '../utils/userPreferences';
const Chat = () => { const Chat = () => {
const [view, setView] = useState('server'); const [view, setView] = useState('server');
@@ -59,7 +61,11 @@ const Chat = () => {
const storedUsername = localStorage.getItem('username'); const storedUsername = localStorage.getItem('username');
const storedUserId = localStorage.getItem('userId'); const storedUserId = localStorage.getItem('userId');
if (storedUsername) setUsername(storedUsername); if (storedUsername) setUsername(storedUsername);
if (storedUserId) setUserId(storedUserId); if (storedUserId) {
setUserId(storedUserId);
const savedView = getUserPref(storedUserId, 'lastView', 'server');
setView(savedView);
}
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -85,11 +91,33 @@ const Chat = () => {
useEffect(() => { useEffect(() => {
if (activeChannel || channels.length === 0) return; if (activeChannel || channels.length === 0) return;
// Try to restore last active channel
if (userId) {
const savedChannel = getUserPref(userId, 'lastActiveChannel', null);
if (savedChannel && channels.some(c => c._id === savedChannel)) {
setActiveChannel(savedChannel);
return;
}
}
const firstTextChannel = channels.find(c => c.type === 'text'); const firstTextChannel = channels.find(c => c.type === 'text');
if (firstTextChannel) { if (firstTextChannel) {
setActiveChannel(firstTextChannel._id); setActiveChannel(firstTextChannel._id);
} }
}, [channels, activeChannel]); }, [channels, activeChannel, userId]);
// Persist active channel
useEffect(() => {
if (activeChannel && userId) {
setUserPref(userId, 'lastActiveChannel', activeChannel);
}
}, [activeChannel, userId]);
// Persist view mode
useEffect(() => {
if (userId) {
setUserPref(userId, 'lastView', view);
}
}, [view, userId]);
const openDM = useCallback(async (targetUserId, targetUsername) => { const openDM = useCallback(async (targetUserId, targetUsername) => {
const uid = localStorage.getItem('userId'); const uid = localStorage.getItem('userId');
@@ -145,12 +173,23 @@ const Chat = () => {
}, []); }, []);
const activeChannelObj = channels.find(c => c._id === activeChannel); const activeChannelObj = channels.find(c => c._id === activeChannel);
const { room, voiceStates } = useVoice(); const { room, voiceStates, activeChannelId: voiceActiveChannelId, 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;
// 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 showPiP = watchingStreamOf !== null && !isViewingVoiceStage;
const handleGoBackToStream = useCallback(() => {
if (voiceActiveChannelId) {
setActiveChannel(voiceActiveChannelId);
setView('server');
}
}, [voiceActiveChannelId]);
function renderMainContent() { function renderMainContent() {
if (view === 'me') { if (view === 'me') {
if (activeDMChannel) { if (activeDMChannel) {
@@ -261,6 +300,7 @@ const Chat = () => {
userId={userId} userId={userId}
/> />
{renderMainContent()} {renderMainContent()}
{showPiP && <FloatingStreamPiP onGoBackToStream={handleGoBackToStream} />}
<ToastContainer /> <ToastContainer />
</div> </div>
</PresenceProvider> </PresenceProvider>

View File

@@ -0,0 +1,118 @@
import { useState, useEffect, useRef } from 'react';
import { RoomEvent } from 'livekit-client';
export const VideoRenderer = ({ track, style }) => {
const videoRef = useRef(null);
useEffect(() => {
const el = videoRef.current;
if (el && track) {
track.attach(el);
return () => {
track.detach(el);
};
}
}, [track]);
return (
<video
ref={videoRef}
style={{ width: '100%', height: '100%', objectFit: 'contain', backgroundColor: 'black', ...style }}
/>
);
};
export function findTrackPubs(participant) {
let cameraPub = null;
let screenSharePub = null;
let screenShareAudioPub = null;
const trackMap = participant.tracks || participant.trackPublications;
if (trackMap) {
for (const pub of trackMap.values()) {
const source = pub.source ? pub.source.toString().toLowerCase() : '';
const name = pub.trackName ? pub.trackName.toLowerCase() : '';
if (source === 'screen_share_audio' || name === 'screen_share_audio') {
screenShareAudioPub = pub;
} else if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) {
screenSharePub = pub;
} else if (source === 'camera' || name.includes('camera')) {
cameraPub = pub;
} else if (pub.kind === 'video' && !screenSharePub) {
screenSharePub = pub;
}
}
}
// Fallback for older SDKs
if ((!screenSharePub && !cameraPub) && participant.getTracks) {
try {
for (const pub of participant.getTracks()) {
const source = pub.source ? pub.source.toString().toLowerCase() : '';
const name = pub.trackName ? pub.trackName.toLowerCase() : '';
if (source === 'screen_share_audio' || name === 'screen_share_audio') {
screenShareAudioPub = pub;
} else if (source === 'screenshare' || source === 'screen_share' || name.includes('screen')) {
screenSharePub = pub;
} else if (source === 'camera' || name.includes('camera')) {
cameraPub = pub;
} else if (pub.kind === 'video' && !screenSharePub) {
screenSharePub = pub;
}
}
} catch (e) { /* ignore */ }
}
return { cameraPub, screenSharePub, screenShareAudioPub };
}
export function useParticipantTrack(participant, source) {
const [track, setTrack] = useState(null);
useEffect(() => {
if (!participant) { setTrack(null); return; }
const resolve = () => {
const { cameraPub, screenSharePub } = findTrackPubs(participant);
const pub = source === 'camera' ? cameraPub : screenSharePub;
if (pub && !pub.isSubscribed && source === 'camera') {
pub.setSubscribed(true);
}
setTrack(pub?.track || null);
};
resolve();
const interval = setInterval(resolve, 1000);
const timeout = setTimeout(() => clearInterval(interval), 10000);
const onPub = () => resolve();
const onUnpub = () => resolve();
participant.on(RoomEvent.TrackPublished, onPub);
participant.on(RoomEvent.TrackUnpublished, onUnpub);
participant.on(RoomEvent.TrackSubscribed, onPub);
participant.on(RoomEvent.TrackUnsubscribed, onUnpub);
participant.on(RoomEvent.TrackMuted, onPub);
participant.on(RoomEvent.TrackUnmuted, onPub);
participant.on('localTrackPublished', onPub);
participant.on('localTrackUnpublished', onUnpub);
return () => {
clearInterval(interval);
clearTimeout(timeout);
participant.off(RoomEvent.TrackPublished, onPub);
participant.off(RoomEvent.TrackUnpublished, onUnpub);
participant.off(RoomEvent.TrackSubscribed, onPub);
participant.off(RoomEvent.TrackUnsubscribed, onUnpub);
participant.off(RoomEvent.TrackMuted, onPub);
participant.off(RoomEvent.TrackUnmuted, onPub);
participant.off('localTrackPublished', onPub);
participant.off('localTrackUnpublished', onUnpub);
};
}, [participant, source]);
return track;
}

View File

@@ -0,0 +1,23 @@
export function getUserPref(userId, key, defaultValue) {
if (!userId) return defaultValue;
try {
const raw = localStorage.getItem(`userPrefs_${userId}`);
if (!raw) return defaultValue;
const prefs = JSON.parse(raw);
return prefs[key] !== undefined ? prefs[key] : defaultValue;
} catch {
return defaultValue;
}
}
export function setUserPref(userId, key, value) {
if (!userId) return;
try {
const raw = localStorage.getItem(`userPrefs_${userId}`);
const prefs = raw ? JSON.parse(raw) : {};
prefs[key] = value;
localStorage.setItem(`userPrefs_${userId}`, JSON.stringify(prefs));
} catch {
// Silently fail on corrupt data or full storage
}
}

View File

@@ -29,3 +29,8 @@
# Future # Future
- Allow users to add custom join sounds. - Allow users to add custom join sounds.
How can we save user preferences for the app like individual user volumes, the position and size they have the floating stream popout, if they have categories collaped, the last channel they were in so we can open that channel when they open the app, etc.