feat: Implement core Discord clone functionality including Convex backend services for authentication, channels, messages, roles, and voice state, alongside new Electron frontend components for chat, voice, server settings, and user interface.
All checks were successful
Build and Release / build-and-release (push) Successful in 14m19s

This commit is contained in:
Bryan1029384756
2026-02-12 04:52:28 -06:00
parent e790db7029
commit 7a5b789ece
30 changed files with 1339 additions and 162 deletions

View File

@@ -11,7 +11,7 @@ import DMList from './DMList';
import Avatar from './Avatar';
import UserSettings from './UserSettings';
import { Track } from 'livekit-client';
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core';
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragOverlay, useDraggable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import muteIcon from '../assets/icons/mute.svg';
@@ -24,13 +24,18 @@ import disconnectIcon from '../assets/icons/disconnect.svg';
import cameraIcon from '../assets/icons/camera.svg';
import screenIcon from '../assets/icons/screen.svg';
import inviteUserIcon from '../assets/icons/invite_user.svg';
import personalMuteIcon from '../assets/icons/personal_mute.svg';
import serverMuteIcon from '../assets/icons/server_mute.svg';
import categoryCollapsedIcon from '../assets/icons/category_collapsed_icon.svg';
import PingSound from '../assets/sounds/ping.mp3';
import screenShareStartSound from '../assets/sounds/screenshare_start.mp3';
import screenShareStopSound from '../assets/sounds/screenshare_stop.mp3';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)';
const ICON_COLOR_ACTIVE = 'hsl(357.692 67.826% 54.902% / 1)';
const SERVER_MUTE_RED = 'color-mix(in oklab, hsl(1.343 calc(1*84.81%) 69.02% /1) 100%, #000 0%)';
const controlButtonStyle = {
background: 'transparent',
@@ -114,6 +119,8 @@ const UserControlPanel = ({ username, userId }) => {
const [currentStatus, setCurrentStatus] = useState('online');
const updateStatusMutation = useMutation(api.auth.updateStatus);
const navigate = useNavigate();
const manualStatusRef = useRef(false);
const preIdleStatusRef = useRef('online');
// Fetch stored status preference from server and sync local state
const allUsers = useQuery(api.auth.getPublicKeys) || [];
@@ -122,9 +129,12 @@ const UserControlPanel = ({ username, userId }) => {
if (myUser) {
if (myUser.status && myUser.status !== 'offline') {
setCurrentStatus(myUser.status);
// dnd/invisible are manual overrides; idle is auto-set so don't count it
manualStatusRef.current = (myUser.status === 'dnd' || myUser.status === 'invisible');
} else if (!myUser.status || myUser.status === 'offline') {
// First login or no preference set yet — default to "online"
setCurrentStatus('online');
manualStatusRef.current = false;
if (userId) {
updateStatusMutation({ userId, status: 'online' }).catch(() => {});
}
@@ -153,6 +163,7 @@ const UserControlPanel = ({ username, userId }) => {
const statusColor = STATUS_OPTIONS.find(s => s.value === currentStatus)?.color || '#3ba55c';
const handleStatusChange = async (status) => {
manualStatusRef.current = (status !== 'online');
setCurrentStatus(status);
setShowStatusMenu(false);
if (userId) {
@@ -164,6 +175,25 @@ const UserControlPanel = ({ username, userId }) => {
}
};
// Auto-idle detection via Electron powerMonitor
useEffect(() => {
if (!window.idleAPI || !userId) return;
const handleIdleChange = (data) => {
if (manualStatusRef.current) return;
if (data.isIdle) {
preIdleStatusRef.current = currentStatus;
setCurrentStatus('idle');
updateStatusMutation({ userId, status: 'idle' }).catch(() => {});
} else {
const restoreTo = preIdleStatusRef.current || 'online';
setCurrentStatus(restoreTo);
updateStatusMutation({ userId, status: restoreTo }).catch(() => {});
}
};
window.idleAPI.onIdleStateChanged(handleIdleChange);
return () => window.idleAPI.removeIdleStateListener();
}, [userId]);
return (
<div style={{
height: '64px',
@@ -331,7 +361,12 @@ function getScreenCaptureConstraints(selection) {
return { video: { deviceId: { exact: selection.deviceId } }, audio: false };
}
return {
audio: false,
audio: selection.shareAudio ? {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: selection.sourceId
}
} : false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
@@ -341,6 +376,82 @@ function getScreenCaptureConstraints(selection) {
};
}
const VoiceUserContextMenu = ({ x, y, onClose, user, onMute, isMuted, onServerMute, isServerMuted, hasPermission, onMessage }) => {
const menuRef = useRef(null);
const [pos, setPos] = useState({ top: y, left: x });
useEffect(() => {
const h = () => onClose();
window.addEventListener('click', h);
return () => window.removeEventListener('click', h);
}, [onClose]);
useLayoutEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
let newTop = y, newLeft = x;
if (x + rect.width > window.innerWidth) newLeft = x - rect.width;
if (y + rect.height > window.innerHeight) newTop = y - rect.height;
if (newLeft < 0) newLeft = 10;
if (newTop < 0) newTop = 10;
setPos({ top: newTop, left: newLeft });
}, [x, y]);
return (
<div ref={menuRef} className="context-menu" style={{ top: pos.top, left: pos.left }} onClick={(e) => e.stopPropagation()}>
<div
className="context-menu-item context-menu-checkbox-item"
role="menuitemcheckbox"
aria-checked={isMuted}
onClick={(e) => { e.stopPropagation(); onMute(); }}
>
<span>Mute</span>
<div className="context-menu-checkbox">
<div className={`context-menu-checkbox-indicator ${isMuted ? 'checked' : ''}`}>
{isMuted ? (
<svg width="18" height="18" viewBox="0 0 24 24">
<path fill="white" d="M8.99991 16.17L4.82991 12L3.40991 13.41L8.99991 19L20.9999 7L19.5899 5.59L8.99991 16.17Z" />
</svg>
) : (
<svg width="10" height="10" viewBox="0 0 10 10">
</svg>
)}
</div>
</div>
</div>
{hasPermission && (
<div
className="context-menu-item context-menu-checkbox-item"
role="menuitemcheckbox"
aria-checked={isServerMuted}
onClick={(e) => { e.stopPropagation(); onServerMute(); }}
>
<span style={{ color: SERVER_MUTE_RED }}>Server Mute</span>
<div className="context-menu-checkbox">
<div
className={`context-menu-checkbox-indicator ${isServerMuted ? 'checked' : ''}`}
style={isServerMuted ? { backgroundColor: SERVER_MUTE_RED, borderColor: SERVER_MUTE_RED } : {}}
>
{isServerMuted ? (
<svg width="18" height="18" viewBox="0 0 24 24">
<path fill="white" d="M8.99991 16.17L4.82991 12L3.40991 13.41L8.99991 19L20.9999 7L19.5899 5.59L8.99991 16.17Z" />
</svg>
) : (
<svg width="10" height="10" viewBox="0 0 10 10">
</svg>
)}
</div>
</div>
</div>
)}
<div className="context-menu-separator" />
<div className="context-menu-item" onClick={(e) => { e.stopPropagation(); onMessage(); onClose(); }}>
<span>Message</span>
</div>
</div>
);
};
const ChannelListContextMenu = ({ x, y, onClose, onCreateChannel, onCreateCategory }) => {
const menuRef = useRef(null);
const [pos, setPos] = useState({ top: y, left: x });
@@ -580,7 +691,29 @@ const SortableChannel = ({ id, children }) => {
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<div ref={setNodeRef} style={style} {...attributes}>
{typeof children === 'function' ? children(listeners) : children}
</div>
);
};
const DraggableVoiceUser = ({ userId, channelId, disabled, children }) => {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `voice-user-${userId}`,
data: { type: 'voice-user', userId, channelId },
disabled,
});
return (
<div
ref={setNodeRef}
{...attributes}
{...listeners}
style={{
opacity: isDragging ? 0.4 : 1,
cursor: disabled ? 'default' : 'grab',
}}
>
{children}
</div>
);
@@ -595,13 +728,21 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
const [collapsedCategories, setCollapsedCategories] = useState({});
const [channelListContextMenu, setChannelListContextMenu] = useState(null);
const [voiceUserMenu, setVoiceUserMenu] = useState(null);
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false);
const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false);
const [createChannelCategoryId, setCreateChannelCategoryId] = useState(null);
const [activeDragItem, setActiveDragItem] = useState(null);
const [dragOverChannelId, setDragOverChannelId] = useState(null);
const convex = useConvex();
// Permissions for move_members gating
const myPermissions = useQuery(
api.roles.getMyPermissions,
userId ? { userId } : "skip"
) || {};
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
@@ -674,7 +815,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
if (activeChannel === id) onSelectChannel(null);
};
const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing } = useVoice();
const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing, isPersonallyMuted, togglePersonalMute, isMuted: selfMuted, toggleMute, serverMute, isServerMuted, serverSettings } = useVoice();
const handleStartCreate = () => {
setIsCreating(true);
@@ -772,7 +913,19 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
await room.localParticipant.setScreenShareEnabled(false);
}
const stream = await navigator.mediaDevices.getUserMedia(getScreenCaptureConstraints(selection));
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia(getScreenCaptureConstraints(selection));
} catch (audioErr) {
// Audio capture may fail (e.g. macOS/Linux) — retry video-only
if (selection.shareAudio) {
console.warn("Audio capture failed, falling back to video-only:", audioErr.message);
stream = await navigator.mediaDevices.getUserMedia(getScreenCaptureConstraints({ ...selection, shareAudio: false }));
} else {
throw audioErr;
}
}
const track = stream.getVideoTracks()[0];
if (!track) return;
@@ -781,9 +934,24 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
source: Track.Source.ScreenShare
});
// Publish audio track if present (system audio from desktop capture)
const audioTrack = stream.getAudioTracks()[0];
if (audioTrack) {
await room.localParticipant.publishTrack(audioTrack, {
name: 'screen_share_audio',
source: Track.Source.ScreenShareAudio
});
}
new Audio(screenShareStartSound).play();
setScreenSharing(true);
track.onended = () => {
// Clean up audio track when video track ends
if (audioTrack) {
audioTrack.stop();
room.localParticipant.unpublishTrack(audioTrack);
}
setScreenSharing(false);
room.localParticipant.setScreenShareEnabled(false).catch(console.error);
};
@@ -795,7 +963,17 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
const handleScreenShareClick = () => {
if (room?.localParticipant.isScreenShareEnabled) {
// Clean up any screen share audio tracks before stopping
for (const pub of room.localParticipant.trackPublications.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') {
if (pub.track) pub.track.stop();
room.localParticipant.unpublishTrack(pub.track);
}
}
room.localParticipant.setScreenShareEnabled(false);
new Audio(screenShareStopSound).play();
setScreenSharing(false);
} else {
setIsScreenShareModalOpen(true);
@@ -828,31 +1006,83 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
return (
<div style={{ marginLeft: 32, marginBottom: 8 }}>
{users.map(user => (
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
<Avatar
username={user.username}
size={24}
style={{
marginRight: 8,
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
<DraggableVoiceUser
key={user.userId}
userId={user.userId}
channelId={channel._id}
disabled={!myPermissions.move_members}
>
<div
className="voice-user-item"
style={{ display: 'flex', alignItems: 'center', marginBottom: 2 }}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
setVoiceUserMenu({ x: e.clientX, y: e.clientY, user });
}}
/>
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{user.username}</span>
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center', marginRight: "16px" }}>
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
{(user.isMuted || user.isDeafened) && (
<ColoredIcon src={mutedIcon} color="var(--header-secondary)" size="14px" />
)}
{user.isDeafened && (
<ColoredIcon src={defeanedIcon} color="var(--header-secondary)" size="14px" />
)}
>
<Avatar
username={user.username}
avatarUrl={user.avatarUrl}
size={24}
style={{
marginRight: 8,
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
}}
/>
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{user.username}</span>
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center', marginRight: "16px" }}>
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
{user.isServerMuted ? (
<ColoredIcon src={serverMuteIcon} color={SERVER_MUTE_RED} size="14px" />
) : isPersonallyMuted(user.userId) ? (
<ColoredIcon src={personalMuteIcon} color="var(--header-secondary)" size="14px" />
) : (user.isMuted || user.isDeafened) ? (
<ColoredIcon src={mutedIcon} color="var(--header-secondary)" size="14px" />
) : null}
{user.isDeafened && (
<ColoredIcon src={defeanedIcon} color="var(--header-secondary)" size="14px" />
)}
</div>
</div>
</div>
</DraggableVoiceUser>
))}
</div>
);
};
const renderCollapsedVoiceUsers = (channel) => {
const users = voiceStates[channel._id];
if (channel.type !== 'voice' || !users?.length) return null;
return (
<div
className={`channel-item ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
onClick={() => handleChannelClick(channel)}
style={{ position: 'relative', display: 'flex', alignItems: 'center', paddingRight: '8px' }}
>
<div style={{ marginRight: 6 }}>
<ColoredIcon src={voiceIcon} size="16px" color={VOICE_ACTIVE_COLOR} />
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
{users.map(user => (
<div key={user.userId} style={{ marginRight: -6, position: 'relative', zIndex: 1 }}>
<Avatar
username={user.username}
avatarUrl={user.avatarUrl}
size={24}
style={{
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none',
borderRadius: '50%',
}}
/>
</div>
))}
</div>
</div>
);
};
const toggleCategory = (cat) => {
setCollapsedCategories(prev => ({ ...prev, [cat]: !prev[cat] }));
};
@@ -901,17 +1131,65 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
const chId = active.id.replace('channel-', '');
const ch = channels.find(c => c._id === chId);
setActiveDragItem({ type: 'channel', channel: ch });
} else if (activeType === 'voice-user') {
const targetUserId = active.data.current.userId;
const sourceChannelId = active.data.current.channelId;
const users = voiceStates[sourceChannelId];
const user = users?.find(u => u.userId === targetUserId);
setActiveDragItem({ type: 'voice-user', user, sourceChannelId });
}
};
const handleDragOver = (event) => {
const { active, over } = event;
if (!active?.data.current || active.data.current.type !== 'voice-user') {
setDragOverChannelId(null);
return;
}
if (over) {
// Check if hovering over a voice channel (channel item or its DnD wrapper)
const overType = over.data.current?.type;
if (overType === 'channel') {
const chId = over.id.replace('channel-', '');
const ch = channels.find(c => c._id === chId);
if (ch?.type === 'voice') {
setDragOverChannelId(ch._id);
return;
}
}
}
setDragOverChannelId(null);
};
const handleDragEnd = async (event) => {
setActiveDragItem(null);
setDragOverChannelId(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const activeType = active.data.current?.type;
const overType = over.data.current?.type;
// Handle voice-user drag
if (activeType === 'voice-user') {
if (overType !== 'channel') return;
const targetChId = over.id.replace('channel-', '');
const targetChannel = channels.find(c => c._id === targetChId);
if (!targetChannel || targetChannel.type !== 'voice') return;
const sourceChannelId = active.data.current.channelId;
if (sourceChannelId === targetChId) return;
try {
await convex.mutation(api.voiceState.moveUser, {
actorUserId: userId,
targetUserId: active.data.current.userId,
targetChannelId: targetChId,
});
} catch (e) {
console.error('Failed to move voice user:', e);
}
return;
}
if (activeType === 'category' && overType === 'category') {
// Reorder categories
const oldIndex = groupedChannels.findIndex(g => `category-${g.id}` === active.id);
@@ -1043,6 +1321,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<SortableContext items={categoryDndIds} strategy={verticalListSortingStrategy}>
@@ -1060,8 +1339,12 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
}}
/>
{(() => {
const visibleChannels = collapsedCategories[group.id]
? group.channels.filter(ch => ch._id === activeChannel)
const isCollapsed = collapsedCategories[group.id];
const visibleChannels = isCollapsed
? group.channels.filter(ch =>
ch._id === activeChannel ||
(ch.type === 'voice' && voiceStates[ch._id]?.length > 0)
)
: group.channels;
if (visibleChannels.length === 0) return null;
const visibleDndIds = visibleChannels.map(ch => `channel-${ch._id}`);
@@ -1071,10 +1354,12 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
const isUnread = activeChannel !== channel._id && unreadChannels.has(channel._id);
return (
<SortableChannel key={channel._id} id={`channel-${channel._id}`}>
{(channelDragListeners) => (
<React.Fragment>
<div
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
{!(isCollapsed && channel.type === 'voice' && voiceStates[channel._id]?.length > 0) && <div
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''} ${dragOverChannelId === channel._id ? 'voice-drop-target' : ''}`}
onClick={() => handleChannelClick(channel)}
{...channelDragListeners}
style={{
position: 'relative',
display: 'flex',
@@ -1096,7 +1381,9 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
) : (
<span style={{ color: 'var(--interactive-normal)', marginRight: '6px', flexShrink: 0 }}>#</span>
)}
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', ...(isUnread ? { color: 'var(--header-primary)', fontWeight: 600 } : {}) }}>{channel.name}</span>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', ...(isUnread ? { color: 'var(--header-primary)', fontWeight: 600 } : {}) }}>
{channel.name}{serverSettings?.afkChannelId === channel._id ? ' (AFK)' : ''}
</span>
</div>
<button
@@ -1115,9 +1402,12 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
>
<ColoredIcon src={settingsIcon} color="var(--interactive-normal)" size="14px" />
</button>
</div>
{renderVoiceUsers(channel)}
</div>}
{isCollapsed
? renderCollapsedVoiceUsers(channel)
: renderVoiceUsers(channel)}
</React.Fragment>
)}
</SortableChannel>
);
})}
@@ -1145,6 +1435,17 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
{activeDragItem.name}
</div>
)}
{activeDragItem?.type === 'voice-user' && activeDragItem.user && (
<div className="drag-overlay-voice-user">
<Avatar
username={activeDragItem.user.username}
avatarUrl={activeDragItem.user.avatarUrl}
size={24}
style={{ marginRight: 8 }}
/>
<span>{activeDragItem.user.username}</span>
</div>
)}
</DragOverlay>
</DndContext>
</div>
@@ -1283,6 +1584,23 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
onCreateCategory={() => setShowCreateCategoryModal(true)}
/>
)}
{voiceUserMenu && (
<VoiceUserContextMenu
x={voiceUserMenu.x}
y={voiceUserMenu.y}
user={voiceUserMenu.user}
onClose={() => setVoiceUserMenu(null)}
isMuted={voiceUserMenu.user.userId === userId ? selfMuted : isPersonallyMuted(voiceUserMenu.user.userId)}
onMute={() => voiceUserMenu.user.userId === userId ? toggleMute() : togglePersonalMute(voiceUserMenu.user.userId)}
isServerMuted={isServerMuted(voiceUserMenu.user.userId)}
onServerMute={() => serverMute(voiceUserMenu.user.userId, !isServerMuted(voiceUserMenu.user.userId))}
hasPermission={!!myPermissions.mute_members}
onMessage={() => {
onOpenDM(voiceUserMenu.user.userId, voiceUserMenu.user.username);
onViewChange('me');
}}
/>
)}
{showCreateChannelModal && (
<CreateChannelModal
categoryId={createChannelCategoryId}