feat: Implement core chat page with channel navigation, direct messages, and voice chat integration.
All checks were successful
Build and Release / build-and-release (push) Successful in 9m12s

This commit is contained in:
Bryan1029384756
2026-02-11 17:44:50 -06:00
parent b0acf93059
commit b0f889cb68
17 changed files with 1075 additions and 142 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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>discord</title>
<script type="module" crossorigin src="./assets/index-DIG5pjLm.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Ckz_sV-I.css">
<script type="module" crossorigin src="./assets/index-BhwDWh5r.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-D8p__dJ4.css">
</head>
<body>
<script>

View File

@@ -1,14 +1,17 @@
{
"name": "discord",
"version": "1.0.2",
"version": "1.0.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "discord",
"version": "1.0.2",
"version": "1.0.7",
"dependencies": {
"@convex-dev/presence": "^0.3.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@livekit/components-react": "^2.9.17",
"@livekit/components-styles": "^1.2.0",
"convex": "^1.31.2",
@@ -375,6 +378,59 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@electron/asar": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "discord",
"private": true,
"version": "1.0.7",
"version": "1.0.8",
"description": "A Discord clone built with Convex, React, and Electron",
"author": "Moyettes",
"type": "module",
@@ -60,6 +60,9 @@
},
"dependencies": {
"@convex-dev/presence": "^0.3.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@livekit/components-react": "^2.9.17",
"@livekit/components-styles": "^1.2.0",
"convex": "^1.31.2",

View File

@@ -0,0 +1 @@
<svg class="icon__29444" 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="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" class=""></path></svg>

After

Width:  |  Height:  |  Size: 302 B

View File

@@ -229,8 +229,8 @@ const MessageItem = React.memo(({
return (
<div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}>
{Object.entries(msg.reactions).map(([emojiName, data]) => (
<div key={emojiName} onClick={() => onReactionClick(msg.id, emojiName, data.me)} style={{ display: 'flex', alignItems: 'center', backgroundColor: data.me ? 'rgba(88, 101, 242, 0.15)' : 'var(--embed-background)', border: data.me ? '1px solid var(--brand-experiment)' : '1px solid transparent', borderRadius: '8px', padding: '2px 6px', cursor: 'pointer', gap: '4px' }}>
<ColoredIcon src={getReactionIcon(emojiName)} size="16px" color={data.me ? null : 'var(--header-secondary)'} />
<div key={emojiName} onClick={() => onReactionClick(msg.id, emojiName, data.me)} style={{ display: 'flex', alignItems: 'center', backgroundColor: data.me ? 'rgba(88, 101, 242, 0.15)' : 'hsl(240 calc(1*4%) 60.784% / 0.0784313725490196)', border: data.me ? '1px solid var(--brand-experiment)' : '1px solid transparent', borderRadius: '8px', padding: '2px 6px', cursor: 'pointer', gap: '4px' }}>
<ColoredIcon src={getReactionIcon(emojiName)} size="16px" color={null} />
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : 'var(--header-secondary)', fontWeight: 600 }}>{data.count}</span>
</div>
))}

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, useLayoutEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useConvex, useMutation, useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
@@ -11,6 +11,9 @@ 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 { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import muteIcon from '../assets/icons/mute.svg';
import mutedIcon from '../assets/icons/muted.svg';
import defeanIcon from '../assets/icons/defean.svg';
@@ -21,6 +24,7 @@ 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 categoryCollapsedIcon from '../assets/icons/category_collapsed_icon.svg';
import PingSound from '../assets/sounds/ping.mp3';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
@@ -337,7 +341,252 @@ function getScreenCaptureConstraints(selection) {
};
}
const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId }) => {
const ChannelListContextMenu = ({ x, y, onClose, onCreateChannel, onCreateCategory }) => {
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" onClick={(e) => { e.stopPropagation(); onCreateChannel(); onClose(); }}>
<span>Create Channel</span>
</div>
<div className="context-menu-item" onClick={(e) => { e.stopPropagation(); onCreateCategory(); onClose(); }}>
<span>Create Category</span>
</div>
</div>
);
};
const CreateChannelModal = ({ onClose, onSubmit, categoryId }) => {
const [channelType, setChannelType] = useState('text');
const [channelName, setChannelName] = useState('');
const handleSubmit = () => {
if (!channelName.trim()) return;
onSubmit(channelName.trim(), channelType, categoryId);
onClose();
};
return (
<div className="create-channel-modal-overlay" onClick={onClose}>
<div className="create-channel-modal" onClick={(e) => e.stopPropagation()}>
<div className="create-channel-modal-header">
<div>
<h2 style={{ margin: 0, color: 'var(--header-primary)', fontSize: '20px', fontWeight: 700 }}>Create Channel</h2>
<p style={{ margin: '4px 0 0', color: 'var(--header-secondary)', fontSize: '12px' }}>in Text Channels</p>
</div>
<button className="create-channel-modal-close" onClick={onClose}>
<svg width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg>
</button>
</div>
<div style={{ padding: '0 16px' }}>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: 'var(--header-secondary)', fontSize: '12px', fontWeight: 700, textTransform: 'uppercase', marginBottom: '8px' }}>Channel Type</label>
<div
className={`channel-type-option ${channelType === 'text' ? 'selected' : ''}`}
onClick={() => setChannelType('text')}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
<span style={{ fontSize: '24px', color: 'var(--interactive-normal)', width: '24px', textAlign: 'center' }}>#</span>
<div>
<div style={{ color: 'var(--header-primary)', fontWeight: 500, fontSize: '16px' }}>Text</div>
<div style={{ color: 'var(--header-secondary)', fontSize: '12px', marginTop: '2px' }}>Send messages, images, GIFs, emoji, opinions, and puns</div>
</div>
</div>
<div className={`channel-type-radio ${channelType === 'text' ? 'selected' : ''}`}>
{channelType === 'text' && <div className="channel-type-radio-dot" />}
</div>
</div>
<div
className={`channel-type-option ${channelType === 'voice' ? 'selected' : ''}`}
onClick={() => setChannelType('voice')}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
<svg width="24" height="24" viewBox="0 0 24 24" style={{ flexShrink: 0, color: 'var(--interactive-normal)' }}>
<path fill="currentColor" d="M11.383 3.07904C11.009 2.92504 10.579 3.01004 10.293 3.29604L6.586 7.00304H3C2.45 7.00304 2 7.45304 2 8.00304V16.003C2 16.553 2.45 17.003 3 17.003H6.586L10.293 20.71C10.579 20.996 11.009 21.082 11.383 20.927C11.757 20.772 12 20.407 12 20.003V4.00304C12 3.59904 11.757 3.23404 11.383 3.07904ZM14 5.00304V7.00304C16.757 7.00304 19 9.24604 19 12.003C19 14.76 16.757 17.003 14 17.003V19.003C17.86 19.003 21 15.863 21 12.003C21 8.14304 17.86 5.00304 14 5.00304ZM14 9.00304V15.003C15.654 15.003 17 13.657 17 12.003C17 10.349 15.654 9.00304 14 9.00304Z" />
</svg>
<div>
<div style={{ color: 'var(--header-primary)', fontWeight: 500, fontSize: '16px' }}>Voice</div>
<div style={{ color: 'var(--header-secondary)', fontSize: '12px', marginTop: '2px' }}>Hang out together with voice, video, and screen share</div>
</div>
</div>
<div className={`channel-type-radio ${channelType === 'voice' ? 'selected' : ''}`}>
{channelType === 'voice' && <div className="channel-type-radio-dot" />}
</div>
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: 'var(--header-secondary)', fontSize: '12px', fontWeight: 700, textTransform: 'uppercase', marginBottom: '8px' }}>Channel Name</label>
<div className="create-channel-name-input-wrapper">
<span style={{ color: 'var(--interactive-normal)', fontSize: '16px', marginRight: '4px' }}>
{channelType === 'text' ? '#' : '🔊'}
</span>
<input
autoFocus
type="text"
placeholder="new-channel"
value={channelName}
onChange={(e) => setChannelName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
className="create-channel-name-input"
/>
</div>
</div>
</div>
<div className="create-channel-modal-footer">
<button className="create-channel-cancel-btn" onClick={onClose}>Cancel</button>
<button
className="create-channel-submit-btn"
onClick={handleSubmit}
disabled={!channelName.trim()}
>
Create Channel
</button>
</div>
</div>
</div>
);
};
const CreateCategoryModal = ({ onClose, onSubmit }) => {
const [categoryName, setCategoryName] = useState('');
const handleSubmit = () => {
if (!categoryName.trim()) return;
onSubmit(categoryName.trim());
onClose();
};
return (
<div className="create-channel-modal-overlay" onClick={onClose}>
<div className="create-channel-modal" onClick={(e) => e.stopPropagation()}>
<div className="create-channel-modal-header">
<h2 style={{ margin: 0, color: 'var(--header-primary)', fontSize: '20px', fontWeight: 700 }}>Create Category</h2>
<button className="create-channel-modal-close" onClick={onClose}>
<svg width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg>
</button>
</div>
<div style={{ padding: '0 16px' }}>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: 'var(--header-secondary)', fontSize: '12px', fontWeight: 700, textTransform: 'uppercase', marginBottom: '8px' }}>Category Name</label>
<div className="create-channel-name-input-wrapper">
<input
autoFocus
type="text"
placeholder="New Category"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
className="create-channel-name-input"
/>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<svg width="16" height="16" viewBox="0 0 24 24" style={{ color: 'var(--interactive-normal)' }}>
<path fill="currentColor" d="M17 11V7C17 4.243 14.757 2 12 2C9.243 2 7 4.243 7 7V11C5.897 11 5 11.896 5 13V20C5 21.103 5.897 22 7 22H17C18.103 22 19 21.103 19 20V13C19 11.896 18.103 11 17 11ZM12 18C11.172 18 10.5 17.328 10.5 16.5C10.5 15.672 11.172 15 12 15C12.828 15 13.5 15.672 13.5 16.5C13.5 17.328 12.828 18 12 18ZM15 11H9V7C9 5.346 10.346 4 12 4C13.654 4 15 5.346 15 7V11Z" />
</svg>
<span style={{ color: 'var(--header-primary)', fontSize: '14px', fontWeight: 500 }}>Private Category</span>
</div>
<div className="category-toggle-switch">
<div className="category-toggle-knob" />
</div>
</div>
<p style={{ color: 'var(--header-secondary)', fontSize: '12px', margin: '0 0 16px', lineHeight: '16px' }}>
By making a category private, only selected members and roles will be able to view this category. Synced channels will automatically match this category's permissions.
</p>
</div>
<div className="create-channel-modal-footer">
<button className="create-channel-cancel-btn" onClick={onClose}>Cancel</button>
<button
className="create-channel-submit-btn"
onClick={handleSubmit}
disabled={!categoryName.trim()}
>
Create Category
</button>
</div>
</div>
</div>
);
};
// --- DnD wrapper components ---
const SortableCategory = ({ id, children }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
data: { type: 'category' },
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
{React.Children.map(children, (child, i) => {
// First child is the category header — attach drag listeners to it
if (i === 0 && React.isValidElement(child)) {
return React.cloneElement(child, { dragListeners: listeners });
}
return child;
})}
</div>
);
};
const SortableChannel = ({ id, children }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
data: { type: 'channel' },
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
{children}
</div>
);
};
const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId }) => {
const [isCreating, setIsCreating] = useState(false);
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
const [newChannelName, setNewChannelName] = useState('');
@@ -345,9 +594,19 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
const [editingChannel, setEditingChannel] = useState(null);
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
const [collapsedCategories, setCollapsedCategories] = useState({});
const [channelListContextMenu, setChannelListContextMenu] = useState(null);
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false);
const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false);
const [createChannelCategoryId, setCreateChannelCategoryId] = useState(null);
const [activeDragItem, setActiveDragItem] = useState(null);
const convex = useConvex();
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
);
// Unread tracking
const channelIds = React.useMemo(() => [
...channels.map(c => c._id),
@@ -597,17 +856,137 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
setCollapsedCategories(prev => ({ ...prev, [cat]: !prev[cat] }));
};
// Group channels by categoryId
const groupedChannels = React.useMemo(() => {
const groups = {};
const groups = [];
const channelsByCategory = new Map();
channels.forEach(ch => {
const cat = ch.type === 'voice'
? (ch.category || 'Voice Channels')
: (ch.category || 'Text Channels');
if (!groups[cat]) groups[cat] = [];
groups[cat].push(ch);
const catId = ch.categoryId || '__uncategorized__';
if (!channelsByCategory.has(catId)) channelsByCategory.set(catId, []);
channelsByCategory.get(catId).push(ch);
});
// Sort channels within each category by position
for (const [, list] of channelsByCategory) {
list.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
}
// Add uncategorized at top
const uncategorized = channelsByCategory.get('__uncategorized__');
if (uncategorized?.length) {
groups.push({ id: '__uncategorized__', name: 'Channels', channels: uncategorized });
}
// Add categories in position order
for (const cat of (categories || [])) {
groups.push({ id: cat._id, name: cat.name, channels: channelsByCategory.get(cat._id) || [] });
}
return groups;
}, [channels]);
}, [channels, categories]);
// DnD items
const categoryDndIds = React.useMemo(() => groupedChannels.map(g => `category-${g.id}`), [groupedChannels]);
const handleDragStart = (event) => {
const { active } = event;
const activeType = active.data.current?.type;
if (activeType === 'category') {
const catId = active.id.replace('category-', '');
const group = groupedChannels.find(g => g.id === catId);
setActiveDragItem({ type: 'category', name: group?.name || '' });
} else if (activeType === 'channel') {
const chId = active.id.replace('channel-', '');
const ch = channels.find(c => c._id === chId);
setActiveDragItem({ type: 'channel', channel: ch });
}
};
const handleDragEnd = async (event) => {
setActiveDragItem(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const activeType = active.data.current?.type;
const overType = over.data.current?.type;
if (activeType === 'category' && overType === 'category') {
// Reorder categories
const oldIndex = groupedChannels.findIndex(g => `category-${g.id}` === active.id);
const newIndex = groupedChannels.findIndex(g => `category-${g.id}` === over.id);
if (oldIndex === -1 || newIndex === -1) return;
// Build reordered array (only real categories, skip uncategorized)
const reordered = [...groupedChannels];
const [moved] = reordered.splice(oldIndex, 1);
reordered.splice(newIndex, 0, moved);
const updates = reordered
.filter(g => g.id !== '__uncategorized__')
.map((g, i) => ({ id: g.id, position: i * 1000 }));
if (updates.length > 0) {
try {
await convex.mutation(api.categories.reorder, { updates });
} catch (e) {
console.error('Failed to reorder categories:', e);
}
}
} else if (activeType === 'channel') {
const activeChId = active.id.replace('channel-', '');
if (overType === 'channel') {
const overChId = over.id.replace('channel-', '');
const activeChannel = channels.find(c => c._id === activeChId);
const overChannel = channels.find(c => c._id === overChId);
if (!activeChannel || !overChannel) return;
const targetCategoryId = overChannel.categoryId;
const targetGroup = groupedChannels.find(g => g.id === (targetCategoryId || '__uncategorized__'));
if (!targetGroup) return;
// Build new order for the target category
const targetChannels = [...targetGroup.channels];
// Remove active channel if it's already in this category
const existingIdx = targetChannels.findIndex(c => c._id === activeChId);
if (existingIdx !== -1) targetChannels.splice(existingIdx, 1);
// Insert at the position of the over channel
const overIdx = targetChannels.findIndex(c => c._id === overChId);
targetChannels.splice(overIdx, 0, activeChannel);
const updates = targetChannels.map((ch, i) => ({
id: ch._id,
categoryId: targetCategoryId,
position: i * 1000,
}));
try {
await convex.mutation(api.channels.reorderChannels, { updates });
} catch (e) {
console.error('Failed to reorder channels:', e);
}
} else if (overType === 'category') {
// Drop channel onto a category header — move it to end of that category
const targetCatId = over.id.replace('category-', '');
const targetCategoryId = targetCatId === '__uncategorized__' ? undefined : targetCatId;
const targetGroup = groupedChannels.find(g => g.id === targetCatId);
const maxPos = (targetGroup?.channels || []).reduce((max, c) => Math.max(max, c.position ?? 0), -1000);
try {
await convex.mutation(api.channels.moveChannel, {
id: activeChId,
categoryId: targetCategoryId,
position: maxPos + 1000,
});
} catch (e) {
console.error('Failed to move channel:', e);
}
}
}
};
const renderServerView = () => (
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
@@ -618,7 +997,12 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</button>
</div>
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--bg-tertiary)' }}>
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--bg-tertiary)' }} onContextMenu={(e) => {
if (!e.target.closest('.channel-item') && !e.target.closest('.channel-category-header')) {
e.preventDefault();
setChannelListContextMenu({ x: e.clientX, y: e.clientY });
}
}}>
{isCreating && (
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
<form onSubmit={handleSubmitCreate}>
@@ -654,72 +1038,114 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</div>
)}
{Object.entries(groupedChannels).map(([category, catChannels]) => (
<div key={category}>
<div className="channel-category-header" onClick={() => toggleCategory(category)}>
<span className="category-label">{category}</span>
<button className="category-add-btn" onClick={(e) => { e.stopPropagation(); handleStartCreate(); }} title="Create Channel">
+
</button>
</div>
{!collapsedCategories[category] && catChannels.map(channel => {
const isUnread = activeChannel !== channel._id && unreadChannels.has(channel._id);
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={categoryDndIds} strategy={verticalListSortingStrategy}>
{groupedChannels.map(group => {
const channelDndIds = group.channels.map(ch => `channel-${ch._id}`);
return (
<React.Fragment key={channel._id}>
<div
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
onClick={() => handleChannelClick(channel)}
style={{
position: 'relative',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingRight: '8px'
}}
>
{isUnread && <div className="channel-unread-indicator" />}
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden', flex: 1 }}>
{channel.type === 'voice' ? (
<div style={{ marginRight: 6 }}>
<ColoredIcon
src={voiceIcon}
size="16px"
color={voiceStates[channel._id]?.length > 0 ? VOICE_ACTIVE_COLOR : "var(--interactive-normal)"}
/>
</div>
) : (
<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>
</div>
<SortableCategory key={group.id} id={`category-${group.id}`}>
<CategoryHeader
group={group}
collapsed={collapsedCategories[group.id]}
onToggle={() => toggleCategory(group.id)}
onAddChannel={() => {
setCreateChannelCategoryId(group.id === '__uncategorized__' ? null : group.id);
setShowCreateChannelModal(true);
}}
/>
{(() => {
const visibleChannels = collapsedCategories[group.id]
? group.channels.filter(ch => ch._id === activeChannel)
: group.channels;
if (visibleChannels.length === 0) return null;
const visibleDndIds = visibleChannels.map(ch => `channel-${ch._id}`);
return (
<SortableContext items={visibleDndIds} strategy={verticalListSortingStrategy}>
{visibleChannels.map(channel => {
const isUnread = activeChannel !== channel._id && unreadChannels.has(channel._id);
return (
<SortableChannel key={channel._id} id={`channel-${channel._id}`}>
<React.Fragment>
<div
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
onClick={() => handleChannelClick(channel)}
style={{
position: 'relative',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingRight: '8px'
}}
>
{isUnread && <div className="channel-unread-indicator" />}
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden', flex: 1 }}>
{channel.type === 'voice' ? (
<div style={{ marginRight: 6 }}>
<ColoredIcon
src={voiceIcon}
size="16px"
color={voiceStates[channel._id]?.length > 0 ? VOICE_ACTIVE_COLOR : "var(--interactive-normal)"}
/>
</div>
) : (
<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>
</div>
<button
className="channel-settings-icon"
onClick={(e) => {
e.stopPropagation();
setEditingChannel(channel);
}}
style={{
background: 'transparent',
border: 'none',
color: 'var(--interactive-normal)',
cursor: 'pointer',
fontSize: '12px',
padding: '2px 4px',
display: 'flex', alignItems: 'center',
opacity: '0',
transition: 'opacity 0.2s'
}}
>
</button>
</div>
{renderVoiceUsers(channel)}
</React.Fragment>
);
<button
className="channel-settings-icon"
onClick={(e) => {
e.stopPropagation();
setEditingChannel(channel);
}}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '2px 4px',
display: 'flex', alignItems: 'center',
}}
>
<ColoredIcon src={settingsIcon} color="var(--interactive-normal)" size="14px" />
</button>
</div>
{renderVoiceUsers(channel)}
</React.Fragment>
</SortableChannel>
);
})}
</SortableContext>
);
})()}
</SortableCategory>
);
})}
</div>
))}
</SortableContext>
<DragOverlay>
{activeDragItem?.type === 'channel' && activeDragItem.channel && (
<div className="drag-overlay-channel">
{activeDragItem.channel.type === 'voice' ? (
<ColoredIcon src={voiceIcon} size="16px" color="var(--interactive-normal)" />
) : (
<span style={{ color: 'var(--interactive-normal)', marginRight: '6px' }}>#</span>
)}
<span>{activeDragItem.channel.name}</span>
</div>
)}
{activeDragItem?.type === 'category' && (
<div className="drag-overlay-category">
{activeDragItem.name}
</div>
)}
</DragOverlay>
</DndContext>
</div>
</div>
);
@@ -844,8 +1270,64 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
onSelectSource={handleScreenShareSelect}
/>
)}
{channelListContextMenu && (
<ChannelListContextMenu
x={channelListContextMenu.x}
y={channelListContextMenu.y}
onClose={() => setChannelListContextMenu(null)}
onCreateChannel={() => {
setCreateChannelCategoryId(null);
setShowCreateChannelModal(true);
}}
onCreateCategory={() => setShowCreateCategoryModal(true)}
/>
)}
{showCreateChannelModal && (
<CreateChannelModal
categoryId={createChannelCategoryId}
onClose={() => setShowCreateChannelModal(false)}
onSubmit={async (name, type, catId) => {
const userId = localStorage.getItem('userId');
if (!userId) { alert("Please login first."); return; }
try {
const createArgs = { name, type };
if (catId) createArgs.categoryId = catId;
const { id: channelId } = await convex.mutation(api.channels.create, createArgs);
const keyHex = randomHex(32);
try { await encryptKeyForUsers(convex, channelId, keyHex); }
catch (keyErr) { console.error("Critical: Failed to distribute keys", keyErr); alert("Channel created but key distribution failed."); }
} catch (err) { console.error(err); alert("Failed to create channel: " + err.message); }
}}
/>
)}
{showCreateCategoryModal && (
<CreateCategoryModal
onClose={() => setShowCreateCategoryModal(false)}
onSubmit={async (name) => {
try {
await convex.mutation(api.categories.create, { name });
} catch (err) {
console.error(err);
alert("Failed to create category: " + err.message);
}
}}
/>
)}
</div>
);
};
// Category header component (extracted for DnD drag handle)
const CategoryHeader = ({ group, collapsed, onToggle, onAddChannel, dragListeners }) => (
<div className="channel-category-header" onClick={onToggle} {...(dragListeners || {})}>
<span className="category-label">{group.name}</span>
<div className={`category-chevron ${collapsed ? 'collapsed' : ''}`}>
<ColoredIcon src={categoryCollapsedIcon} color="currentColor" size="12px" />
</div>
<button className="category-add-btn" onClick={(e) => { e.stopPropagation(); onAddChannel(); }} title="Create Channel">
+
</button>
</div>
);
export default Sidebar;

View File

@@ -1930,7 +1930,8 @@ body {
opacity: 0;
}
.channel-item:hover .channel-settings-icon {
.channel-item:hover .channel-settings-icon,
.channel-item.active .channel-settings-icon {
opacity: 1;
}
@@ -2016,7 +2017,7 @@ body {
}
.category-chevron {
margin-right: 2px;
margin-left: 4px;
font-size: 10px;
transition: transform 0.2s;
flex-shrink: 0;
@@ -2027,13 +2028,13 @@ body {
}
.category-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-add-btn {
margin-left: auto;
background: none;
border: none;
color: var(--interactive-normal);
@@ -2602,4 +2603,220 @@ body {
border: 3px solid var(--bg-tertiary);
border-radius: 50%;
z-index: 1;
}
/* ============================================
CREATE CHANNEL MODAL
============================================ */
.create-channel-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
}
.create-channel-modal {
width: 440px;
max-width: 90vw;
background-color: var(--bg-primary);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.create-channel-modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px 16px 12px;
}
.create-channel-modal-close {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
line-height: 1;
display: flex;
align-items: center;
}
.create-channel-modal-close:hover {
color: var(--text-normal);
}
.channel-type-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 4px;
cursor: pointer;
background-color: var(--bg-secondary);
margin-bottom: 8px;
transition: background-color 0.1s;
}
.channel-type-option:hover {
background-color: var(--background-modifier-hover);
}
.channel-type-option.selected {
background-color: var(--background-modifier-selected);
}
.channel-type-radio {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid var(--interactive-normal);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: border-color 0.15s;
}
.channel-type-radio.selected {
border-color: var(--brand-experiment);
}
.channel-type-radio-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--brand-experiment);
}
.create-channel-name-input-wrapper {
display: flex;
align-items: center;
background-color: var(--bg-tertiary);
border-radius: 4px;
padding: 8px 12px;
}
.create-channel-name-input {
flex: 1;
background: transparent;
border: none;
color: var(--text-normal);
font-size: 16px;
font-family: inherit;
outline: none;
padding: 0;
}
.create-channel-name-input::placeholder {
color: var(--text-muted);
}
.create-channel-modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 16px;
background-color: var(--bg-secondary);
gap: 12px;
}
.create-channel-cancel-btn {
background: none;
border: none;
color: var(--text-normal);
cursor: pointer;
font-size: 14px;
font-weight: 500;
padding: 8px 16px;
font-family: inherit;
}
.create-channel-cancel-btn:hover {
text-decoration: underline;
}
.create-channel-submit-btn {
background-color: var(--brand-experiment);
color: white;
border: none;
border-radius: 3px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
font-family: inherit;
transition: background-color 0.15s;
}
.create-channel-submit-btn:hover {
background-color: var(--brand-experiment-hover);
}
.create-channel-submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ============================================
CATEGORY TOGGLE SWITCH (visual only)
============================================ */
.category-toggle-switch {
width: 40px;
height: 24px;
background-color: var(--interactive-normal);
border-radius: 12px;
position: relative;
cursor: pointer;
flex-shrink: 0;
opacity: 0.5;
}
.category-toggle-knob {
width: 18px;
height: 18px;
background-color: var(--header-primary);
border-radius: 50%;
position: absolute;
top: 3px;
left: 3px;
transition: left 0.2s;
}
/* ============================================
DRAG OVERLAY
============================================ */
.drag-overlay-channel {
display: flex;
align-items: center;
padding: 8px;
background-color: var(--background-modifier-selected);
border-radius: 4px;
color: var(--interactive-active);
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
cursor: grabbing;
opacity: 0.9;
width: 200px;
}
.drag-overlay-category {
padding: 8px 12px;
background-color: var(--bg-secondary);
border-radius: 4px;
color: var(--text-muted);
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.02em;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
cursor: grabbing;
opacity: 0.9;
width: 200px;
}

View File

@@ -43,6 +43,7 @@ const Chat = () => {
}, [toggleMute]);
const channels = useQuery(api.channels.list) || [];
const categories = useQuery(api.categories.list) || [];
const rawChannelKeys = useQuery(
api.channelKeys.getKeysForUser,
@@ -246,6 +247,7 @@ const Chat = () => {
<div className="app-container">
<Sidebar
channels={channels}
categories={categories}
activeChannel={activeChannel}
onSelectChannel={handleSelectChannel}
username={username}