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

View File

@@ -14,9 +14,10 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
## Key Convex Files (convex/) ## Key Convex Files (convex/)
- `schema.ts` - Full schema: userProfiles (with avatarStorageId, aboutMe, customStatus), channels (with category, topic, position), messages, messageReactions, channelKeys, roles, userRoles, invites, dmParticipants, typingIndicators, voiceStates, channelReadState - `schema.ts` - Full schema: userProfiles (with avatarStorageId, aboutMe, customStatus), categories (name, position), channels (with categoryId, topic, position), messages, messageReactions, channelKeys, roles, userRoles, invites, dmParticipants, typingIndicators, voiceStates, channelReadState
- `auth.ts` - getSalt, verifyUser, createUserWithProfile, getPublicKeys (includes avatarUrl, aboutMe, customStatus), updateProfile, updateStatus - `auth.ts` - getSalt, verifyUser, createUserWithProfile, getPublicKeys (includes avatarUrl, aboutMe, customStatus), updateProfile, updateStatus
- `channels.ts` - list, get, create (with category/topic/position), rename, remove (cascade), updateTopic - `categories.ts` - list, create, rename, remove, reorder
- `channels.ts` - list, get, create (with categoryId/topic/position), rename, remove (cascade), updateTopic, moveChannel, reorderChannels
- `members.ts` - getChannelMembers (includes isHoist on roles, avatarUrl, aboutMe, customStatus) - `members.ts` - getChannelMembers (includes isHoist on roles, avatarUrl, aboutMe, customStatus)
- `channelKeys.ts` - uploadKeys, getKeysForUser - `channelKeys.ts` - uploadKeys, getKeysForUser
- `messages.ts` - list (with reactions + username), send, remove - `messages.ts` - list (with reactions + username), send, remove
@@ -36,9 +37,9 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
- `main.jsx` - ConvexProvider + VoiceProvider + HashRouter - `main.jsx` - ConvexProvider + VoiceProvider + HashRouter
- `pages/Login.jsx` - Convex auth (getSalt + verifyUser) - `pages/Login.jsx` - Convex auth (getSalt + verifyUser)
- `pages/Register.jsx` - Convex auth (createUserWithProfile + invite flow) - `pages/Register.jsx` - Convex auth (createUserWithProfile + invite flow)
- `pages/Chat.jsx` - useQuery for channels, channelKeys, DMs - `pages/Chat.jsx` - useQuery for channels, categories, channelKeys, DMs
- `components/ChatArea.jsx` - Messages, typing, reactions via Convex queries/mutations - `components/ChatArea.jsx` - Messages, typing, reactions via Convex queries/mutations
- `components/Sidebar.jsx` - Channel creation, key distribution, invites via Convex - `components/Sidebar.jsx` - Channel/category creation, key distribution, invites, drag-and-drop reordering via @dnd-kit
- `contexts/VoiceContext.jsx` - Voice state via Convex + LiveKit room management - `contexts/VoiceContext.jsx` - Voice state via Convex + LiveKit room management
- `components/ChannelSettingsModal.jsx` - Channel rename/delete via Convex mutations - `components/ChannelSettingsModal.jsx` - Channel rename/delete via Convex mutations
- `components/ServerSettingsModal.jsx` - Role management via Convex queries/mutations - `components/ServerSettingsModal.jsx` - Role management via Convex queries/mutations
@@ -62,7 +63,8 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
- Typing indicators use scheduled functions for TTL cleanup - Typing indicators use scheduled functions for TTL cleanup
- CSS uses Discord dark theme colors via `:root` variables (`--bg-primary: #313338`, `--bg-secondary: #2b2d31`, `--bg-tertiary: #1e1f22`) - CSS uses Discord dark theme colors via `:root` variables (`--bg-primary: #313338`, `--bg-secondary: #2b2d31`, `--bg-tertiary: #1e1f22`)
- Sidebar width is 312px (72px server strip + 240px channel panel) - Sidebar width is 312px (72px server strip + 240px channel panel)
- Channels are grouped by `category` field with collapsible headers - Channels are grouped by `categoryId` (references `categories` table) with collapsible headers and drag-and-drop reordering (@dnd-kit)
- Categories are first-class entities with position-based ordering; uncategorized channels show under "Channels" group
- Members list groups by hoisted roles (isHoist) then Online/Offline - Members list groups by hoisted roles (isHoist) then Online/Offline
- Avatar component supports both image URLs and colored-initial fallback - Avatar component supports both image URLs and colored-initial fallback
- Title bar has back/forward navigation arrows - Title bar has back/forward navigation arrows

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-DIG5pjLm.js"></script> <script type="module" crossorigin src="./assets/index-BhwDWh5r.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Ckz_sV-I.css"> <link rel="stylesheet" crossorigin href="./assets/index-D8p__dJ4.css">
</head> </head>
<body> <body>
<script> <script>

View File

@@ -1,14 +1,17 @@
{ {
"name": "discord", "name": "discord",
"version": "1.0.2", "version": "1.0.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "discord", "name": "discord",
"version": "1.0.2", "version": "1.0.7",
"dependencies": { "dependencies": {
"@convex-dev/presence": "^0.3.0", "@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-react": "^2.9.17",
"@livekit/components-styles": "^1.2.0", "@livekit/components-styles": "^1.2.0",
"convex": "^1.31.2", "convex": "^1.31.2",
@@ -375,6 +378,59 @@
"url": "https://opencollective.com/webpack" "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": { "node_modules/@electron/asar": {
"version": "3.4.1", "version": "3.4.1",
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",

View File

@@ -1,7 +1,7 @@
{ {
"name": "discord", "name": "discord",
"private": true, "private": true,
"version": "1.0.7", "version": "1.0.8",
"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",
@@ -60,6 +60,9 @@
}, },
"dependencies": { "dependencies": {
"@convex-dev/presence": "^0.3.0", "@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-react": "^2.9.17",
"@livekit/components-styles": "^1.2.0", "@livekit/components-styles": "^1.2.0",
"convex": "^1.31.2", "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 ( return (
<div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}>
{Object.entries(msg.reactions).map(([emojiName, data]) => ( {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' }}> <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={data.me ? null : 'var(--header-secondary)'} /> <ColoredIcon src={getReactionIcon(emojiName)} size="16px" color={null} />
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : 'var(--header-secondary)', fontWeight: 600 }}>{data.count}</span> <span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : 'var(--header-secondary)', fontWeight: 600 }}>{data.count}</span>
</div> </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 { useNavigate } from 'react-router-dom';
import { useConvex, useMutation, useQuery } from 'convex/react'; import { useConvex, useMutation, useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api'; import { api } from '../../../../convex/_generated/api';
@@ -11,6 +11,9 @@ import DMList from './DMList';
import Avatar from './Avatar'; import Avatar from './Avatar';
import UserSettings from './UserSettings'; import UserSettings from './UserSettings';
import { Track } from 'livekit-client'; 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 muteIcon from '../assets/icons/mute.svg';
import mutedIcon from '../assets/icons/muted.svg'; import mutedIcon from '../assets/icons/muted.svg';
import defeanIcon from '../assets/icons/defean.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 cameraIcon from '../assets/icons/camera.svg';
import screenIcon from '../assets/icons/screen.svg'; import screenIcon from '../assets/icons/screen.svg';
import inviteUserIcon from '../assets/icons/invite_user.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'; import PingSound from '../assets/sounds/ping.mp3';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245']; 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 [isCreating, setIsCreating] = useState(false);
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false); const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
const [newChannelName, setNewChannelName] = useState(''); const [newChannelName, setNewChannelName] = useState('');
@@ -345,9 +594,19 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
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({});
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(); const convex = useConvex();
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
);
// Unread tracking // Unread tracking
const channelIds = React.useMemo(() => [ const channelIds = React.useMemo(() => [
...channels.map(c => c._id), ...channels.map(c => c._id),
@@ -597,17 +856,137 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
setCollapsedCategories(prev => ({ ...prev, [cat]: !prev[cat] })); setCollapsedCategories(prev => ({ ...prev, [cat]: !prev[cat] }));
}; };
// Group channels by categoryId
const groupedChannels = React.useMemo(() => { const groupedChannels = React.useMemo(() => {
const groups = {}; const groups = [];
const channelsByCategory = new Map();
channels.forEach(ch => { channels.forEach(ch => {
const cat = ch.type === 'voice' const catId = ch.categoryId || '__uncategorized__';
? (ch.category || 'Voice Channels') if (!channelsByCategory.has(catId)) channelsByCategory.set(catId, []);
: (ch.category || 'Text Channels'); channelsByCategory.get(catId).push(ch);
if (!groups[cat]) groups[cat] = [];
groups[cat].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; 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 = () => ( 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' }}> <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> </button>
</div> </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 && ( {isCreating && (
<div style={{ padding: '0 8px', marginBottom: '4px' }}> <div style={{ padding: '0 8px', marginBottom: '4px' }}>
<form onSubmit={handleSubmitCreate}> <form onSubmit={handleSubmitCreate}>
@@ -654,18 +1038,39 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</div> </div>
)} )}
{Object.entries(groupedChannels).map(([category, catChannels]) => ( <DndContext
<div key={category}> sensors={sensors}
<div className="channel-category-header" onClick={() => toggleCategory(category)}> collisionDetection={closestCenter}
<span className="category-label">{category}</span> onDragStart={handleDragStart}
<button className="category-add-btn" onClick={(e) => { e.stopPropagation(); handleStartCreate(); }} title="Create Channel"> onDragEnd={handleDragEnd}
+ >
</button> <SortableContext items={categoryDndIds} strategy={verticalListSortingStrategy}>
</div> {groupedChannels.map(group => {
{!collapsedCategories[category] && catChannels.map(channel => { const channelDndIds = group.channels.map(ch => `channel-${ch._id}`);
return (
<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); const isUnread = activeChannel !== channel._id && unreadChannels.has(channel._id);
return ( return (
<React.Fragment key={channel._id}> <SortableChannel key={channel._id} id={`channel-${channel._id}`}>
<React.Fragment>
<div <div
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`} className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
onClick={() => handleChannelClick(channel)} onClick={() => handleChannelClick(channel)}
@@ -702,24 +1107,45 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
style={{ style={{
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
color: 'var(--interactive-normal)',
cursor: 'pointer', cursor: 'pointer',
fontSize: '12px',
padding: '2px 4px', padding: '2px 4px',
display: 'flex', alignItems: 'center', display: 'flex', alignItems: 'center',
opacity: '0',
transition: 'opacity 0.2s'
}} }}
> >
<ColoredIcon src={settingsIcon} color="var(--interactive-normal)" size="14px" />
</button> </button>
</div> </div>
{renderVoiceUsers(channel)} {renderVoiceUsers(channel)}
</React.Fragment> </React.Fragment>
</SortableChannel>
); );
})} })}
</SortableContext>
);
})()}
</SortableCategory>
);
})}
</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> </div>
))} )}
{activeDragItem?.type === 'category' && (
<div className="drag-overlay-category">
{activeDragItem.name}
</div>
)}
</DragOverlay>
</DndContext>
</div> </div>
</div> </div>
); );
@@ -844,8 +1270,64 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
onSelectSource={handleScreenShareSelect} 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> </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; export default Sidebar;

View File

@@ -1930,7 +1930,8 @@ body {
opacity: 0; opacity: 0;
} }
.channel-item:hover .channel-settings-icon { .channel-item:hover .channel-settings-icon,
.channel-item.active .channel-settings-icon {
opacity: 1; opacity: 1;
} }
@@ -2016,7 +2017,7 @@ body {
} }
.category-chevron { .category-chevron {
margin-right: 2px; margin-left: 4px;
font-size: 10px; font-size: 10px;
transition: transform 0.2s; transition: transform 0.2s;
flex-shrink: 0; flex-shrink: 0;
@@ -2027,13 +2028,13 @@ body {
} }
.category-label { .category-label {
flex: 1;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.category-add-btn { .category-add-btn {
margin-left: auto;
background: none; background: none;
border: none; border: none;
color: var(--interactive-normal); color: var(--interactive-normal);
@@ -2603,3 +2604,219 @@ body {
border-radius: 50%; border-radius: 50%;
z-index: 1; 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]); }, [toggleMute]);
const channels = useQuery(api.channels.list) || []; const channels = useQuery(api.channels.list) || [];
const categories = useQuery(api.categories.list) || [];
const rawChannelKeys = useQuery( const rawChannelKeys = useQuery(
api.channelKeys.getKeysForUser, api.channelKeys.getKeysForUser,
@@ -246,6 +247,7 @@ const Chat = () => {
<div className="app-container"> <div className="app-container">
<Sidebar <Sidebar
channels={channels} channels={channels}
categories={categories}
activeChannel={activeChannel} activeChannel={activeChannel}
onSelectChannel={handleSelectChannel} onSelectChannel={handleSelectChannel}
username={username} username={username}

View File

@@ -10,3 +10,10 @@
- In the mention list we should also have roles, so if people do @everyone they should mention everyone in the server, or they can @ a certain role they want to mention from the server. This does not apply to private messages. - In the mention list we should also have roles, so if people do @everyone they should mention everyone in the server, or they can @ a certain role they want to mention from the server. This does not apply to private messages.
- Owners should be able to delete anyones message in the server. - Owners should be able to delete anyones message in the server.
- When we collapse a category and lets say for example it has a text channel in that category and we have it selected we should still show that text channel but all the others are collapsed
- Next to the category name lets put the category_collapsed_icon.svg icon. This icon is facing down so we will show it normal when a category is not collapsed and rotate it -45 degrees when it is collapsed
For reactions that we didnt react to we have the background to var(--embed-background), lets make it
hsl(240 calc(1*4%) 60.784% /0.0784313725490196)

View File

@@ -9,6 +9,7 @@
*/ */
import type * as auth from "../auth.js"; import type * as auth from "../auth.js";
import type * as categories from "../categories.js";
import type * as channelKeys from "../channelKeys.js"; import type * as channelKeys from "../channelKeys.js";
import type * as channels from "../channels.js"; import type * as channels from "../channels.js";
import type * as dms from "../dms.js"; import type * as dms from "../dms.js";
@@ -33,6 +34,7 @@ import type {
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
auth: typeof auth; auth: typeof auth;
categories: typeof categories;
channelKeys: typeof channelKeys; channelKeys: typeof channelKeys;
channels: typeof channels; channels: typeof channels;
dms: typeof dms; dms: typeof dms;

99
convex/categories.ts Normal file
View File

@@ -0,0 +1,99 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
// List all categories ordered by position
export const list = query({
args: {},
returns: v.array(
v.object({
_id: v.id("categories"),
_creationTime: v.number(),
name: v.string(),
position: v.number(),
})
),
handler: async (ctx) => {
return await ctx.db
.query("categories")
.withIndex("by_position")
.collect();
},
});
// Create a new category
export const create = mutation({
args: { name: v.string() },
returns: v.object({ id: v.id("categories") }),
handler: async (ctx, args) => {
const name = args.name.trim();
if (!name) throw new Error("Category name required");
// Auto-assign position at end
const all = await ctx.db.query("categories").collect();
const maxPos = all.reduce((max, c) => Math.max(max, c.position), -1000);
const id = await ctx.db.insert("categories", {
name,
position: maxPos + 1000,
});
return { id };
},
});
// Rename a category
export const rename = mutation({
args: {
id: v.id("categories"),
name: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const name = args.name.trim();
if (!name) throw new Error("Category name required");
const cat = await ctx.db.get(args.id);
if (!cat) throw new Error("Category not found");
await ctx.db.patch(args.id, { name });
return null;
},
});
// Delete a category (moves its channels to uncategorized)
export const remove = mutation({
args: { id: v.id("categories") },
returns: v.null(),
handler: async (ctx, args) => {
const cat = await ctx.db.get(args.id);
if (!cat) throw new Error("Category not found");
// Move channels to uncategorized
const channels = await ctx.db
.query("channels")
.withIndex("by_category", (q) => q.eq("categoryId", args.id))
.collect();
for (const ch of channels) {
await ctx.db.patch(ch._id, { categoryId: undefined });
}
await ctx.db.delete(args.id);
return null;
},
});
// Batch reorder categories
export const reorder = mutation({
args: {
updates: v.array(
v.object({
id: v.id("categories"),
position: v.number(),
})
),
},
returns: v.null(),
handler: async (ctx, args) => {
for (const u of args.updates) {
await ctx.db.patch(u.id, { position: u.position });
}
return null;
},
});

View File

@@ -32,7 +32,7 @@ export const list = query({
_creationTime: v.number(), _creationTime: v.number(),
name: v.string(), name: v.string(),
type: v.string(), type: v.string(),
category: v.optional(v.string()), categoryId: v.optional(v.id("categories")),
topic: v.optional(v.string()), topic: v.optional(v.string()),
position: v.optional(v.number()), position: v.optional(v.number()),
}) })
@@ -54,7 +54,7 @@ export const get = query({
_creationTime: v.number(), _creationTime: v.number(),
name: v.string(), name: v.string(),
type: v.string(), type: v.string(),
category: v.optional(v.string()), categoryId: v.optional(v.id("categories")),
topic: v.optional(v.string()), topic: v.optional(v.string()),
position: v.optional(v.number()), position: v.optional(v.number()),
}), }),
@@ -70,7 +70,7 @@ export const create = mutation({
args: { args: {
name: v.string(), name: v.string(),
type: v.optional(v.string()), type: v.optional(v.string()),
category: v.optional(v.string()), categoryId: v.optional(v.id("categories")),
topic: v.optional(v.string()), topic: v.optional(v.string()),
position: v.optional(v.number()), position: v.optional(v.number()),
}, },
@@ -89,12 +89,26 @@ export const create = mutation({
throw new Error("Channel already exists"); throw new Error("Channel already exists");
} }
// Auto-calculate position if not provided
let position = args.position;
if (position === undefined) {
const allChannels = await ctx.db.query("channels").collect();
const sameCategory = allChannels.filter(
(c) => c.categoryId === args.categoryId && c.type !== "dm"
);
const maxPos = sameCategory.reduce(
(max, c) => Math.max(max, c.position ?? 0),
-1000
);
position = maxPos + 1000;
}
const id = await ctx.db.insert("channels", { const id = await ctx.db.insert("channels", {
name: args.name, name: args.name,
type: args.type || "text", type: args.type || "text",
category: args.category, categoryId: args.categoryId,
topic: args.topic, topic: args.topic,
position: args.position, position,
}); });
return { id }; return { id };
@@ -127,7 +141,7 @@ export const rename = mutation({
_creationTime: v.number(), _creationTime: v.number(),
name: v.string(), name: v.string(),
type: v.string(), type: v.string(),
category: v.optional(v.string()), categoryId: v.optional(v.id("categories")),
topic: v.optional(v.string()), topic: v.optional(v.string()),
position: v.optional(v.number()), position: v.optional(v.number()),
}), }),
@@ -146,6 +160,48 @@ export const rename = mutation({
}, },
}); });
// Move a channel to a different category with a new position
export const moveChannel = mutation({
args: {
id: v.id("channels"),
categoryId: v.optional(v.id("categories")),
position: v.number(),
},
returns: v.null(),
handler: async (ctx, args) => {
const channel = await ctx.db.get(args.id);
if (!channel) throw new Error("Channel not found");
await ctx.db.patch(args.id, {
categoryId: args.categoryId,
position: args.position,
});
return null;
},
});
// Batch reorder channels
export const reorderChannels = mutation({
args: {
updates: v.array(
v.object({
id: v.id("channels"),
categoryId: v.optional(v.id("categories")),
position: v.number(),
})
),
},
returns: v.null(),
handler: async (ctx, args) => {
for (const u of args.updates) {
await ctx.db.patch(u.id, {
categoryId: u.categoryId,
position: u.position,
});
}
return null;
},
});
// Delete channel + cascade messages and keys // Delete channel + cascade messages and keys
export const remove = mutation({ export const remove = mutation({
args: { id: v.id("channels") }, args: { id: v.id("channels") },

View File

@@ -18,13 +18,19 @@ export default defineSchema({
customStatus: v.optional(v.string()), customStatus: v.optional(v.string()),
}).index("by_username", ["username"]), }).index("by_username", ["username"]),
categories: defineTable({
name: v.string(),
position: v.number(),
}).index("by_position", ["position"]),
channels: defineTable({ channels: defineTable({
name: v.string(), name: v.string(),
type: v.string(), // 'text' | 'voice' | 'dm' type: v.string(), // 'text' | 'voice' | 'dm'
category: v.optional(v.string()), categoryId: v.optional(v.id("categories")),
topic: v.optional(v.string()), topic: v.optional(v.string()),
position: v.optional(v.number()), position: v.optional(v.number()),
}).index("by_name", ["name"]), }).index("by_name", ["name"])
.index("by_category", ["categoryId"]),
messages: defineTable({ messages: defineTable({
channelId: v.id("channels"), channelId: v.id("channels"),