feat: Initialize the Electron frontend with core UI components and integrate Convex backend services.

This commit is contained in:
Bryan1029384756
2026-02-10 18:29:42 -06:00
parent 34e9790db9
commit 17790afa9b
64 changed files with 149216 additions and 628 deletions

View File

@@ -11,7 +11,9 @@
"Bash(npx convex:*)", "Bash(npx convex:*)",
"Bash(npx @convex-dev/auth:*)", "Bash(npx @convex-dev/auth:*)",
"Bash(dir:*)", "Bash(dir:*)",
"Bash(npx vite build:*)" "Bash(npx vite build:*)",
"Bash(npx tsc:*)",
"Bash(npx -y esbuild:*)"
] ]
} }
} }

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ node_modules
.env.local .env.local
.vscode .vscode
./backend/uploads/ ./backend/uploads/
./discord-html-copy

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, channels, messages, messageReactions, channelKeys, roles, userRoles, invites, dmParticipants, typingIndicators, voiceStates - `schema.ts` - Full schema: userProfiles (with avatarStorageId, aboutMe, customStatus), channels (with category, topic, position), messages, messageReactions, channelKeys, roles, userRoles, invites, dmParticipants, typingIndicators, voiceStates
- `auth.ts` - getSalt, verifyUser, createUserWithProfile, getPublicKeys - `auth.ts` - getSalt, verifyUser, createUserWithProfile, getPublicKeys (includes avatarUrl, aboutMe, customStatus), updateProfile, updateStatus
- `channels.ts` - list, get, create, rename, remove (with cascade delete) - `channels.ts` - list, get, create (with category/topic/position), rename, remove (cascade), updateTopic
- `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
- `reactions.ts` - add, remove - `reactions.ts` - add, remove
@@ -40,6 +41,7 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
- `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
- `components/Avatar.jsx` - Reusable avatar component (image or colored-initial fallback)
- `components/FriendsView.jsx` - User list via Convex query - `components/FriendsView.jsx` - User list via Convex query
- `components/DMList.jsx` - DM user picker via Convex query - `components/DMList.jsx` - DM user picker via Convex query
- `components/GifPicker.jsx` - GIF search via Convex action - `components/GifPicker.jsx` - GIF search via Convex action
@@ -56,6 +58,15 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
- Convex queries are reactive - no need for manual refresh or socket listeners - Convex queries are reactive - no need for manual refresh or socket listeners
- File uploads use Convex storage: `generateUploadUrl` -> POST blob -> `getFileUrl` - File uploads use Convex storage: `generateUploadUrl` -> POST blob -> `getFileUrl`
- 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`)
- Sidebar width is 312px (72px server strip + 240px channel panel)
- Channels are grouped by `category` field with collapsible headers
- Members list groups by hoisted roles (isHoist) then Online/Offline
- Avatar component supports both image URLs and colored-initial fallback
- Title bar has back/forward navigation arrows
- Chat header includes thread, pin, members, notification icons + channel topic
- Voice connected panel includes elapsed time timer
- Keyboard shortcuts: Ctrl+K (quick switcher), Ctrl+Shift+M (mute toggle)
## Environment Variables ## Environment Variables

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,10 +5,16 @@
<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-DXKRzYO-.js"></script> <script type="module" crossorigin src="./assets/index-XO0EnFCR.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-D1fin5Al.css"> <link rel="stylesheet" crossorigin href="./assets/index-0wNLL1lc.css">
</head> </head>
<body> <body>
<script>
(function() {
var t = localStorage.getItem('discord-theme') || 'theme-dark';
document.documentElement.className = t;
})();
</script>
<div id="root"></div> <div id="root"></div>
</body> </body>
</html> </html>

View File

@@ -7,6 +7,12 @@
<title>discord</title> <title>discord</title>
</head> </head>
<body> <body>
<script>
(function() {
var t = localStorage.getItem('discord-theme') || 'theme-dark';
document.documentElement.className = t;
})();
</script>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>

View File

@@ -7,6 +7,7 @@ function createWindow() {
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1200, width: 1200,
height: 800, height: 800,
frame: false,
webPreferences: { webPreferences: {
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
@@ -30,6 +31,22 @@ function createWindow() {
app.whenReady().then(() => { app.whenReady().then(() => {
createWindow(); createWindow();
ipcMain.on('window-minimize', () => {
const win = BrowserWindow.getFocusedWindow();
if (win) win.minimize();
});
ipcMain.on('window-maximize', () => {
const win = BrowserWindow.getFocusedWindow();
if (win) {
if (win.isMaximized()) win.unmaximize();
else win.maximize();
}
});
ipcMain.on('window-close', () => {
const win = BrowserWindow.getFocusedWindow();
if (win) win.close();
});
// Helper to fetch metadata (Zero-Knowledge: Client fetches previews) // Helper to fetch metadata (Zero-Knowledge: Client fetches previews)
ipcMain.handle('fetch-metadata', async (event, url) => { ipcMain.handle('fetch-metadata', async (event, url) => {
return new Promise((resolve) => { return new Promise((resolve) => {

View File

@@ -18,3 +18,9 @@ contextBridge.exposeInMainWorld('cryptoAPI', {
openExternal: (url) => ipcRenderer.invoke('open-external', url), openExternal: (url) => ipcRenderer.invoke('open-external', url),
getScreenSources: () => ipcRenderer.invoke('get-screen-sources'), getScreenSources: () => ipcRenderer.invoke('get-screen-sources'),
}); });
contextBridge.exposeInMainWorld('windowControls', {
minimize: () => ipcRenderer.send('window-minimize'),
maximize: () => ipcRenderer.send('window-maximize'),
close: () => ipcRenderer.send('window-close'),
});

View File

@@ -0,0 +1,62 @@
import React from 'react';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
function getUserColor(name) {
let hash = 0;
for (let i = 0; i < (name || '').length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
}
const Avatar = ({ username, avatarUrl, size = 40, className = '', style = {}, onClick }) => {
const sizeStr = `${size}px`;
const fontSize = `${Math.max(size * 0.45, 10)}px`;
if (avatarUrl) {
return (
<img
className={className}
src={avatarUrl}
alt={username || '?'}
onClick={onClick}
style={{
width: sizeStr,
height: sizeStr,
borderRadius: '50%',
objectFit: 'cover',
cursor: onClick ? 'pointer' : 'default',
...style,
}}
/>
);
}
return (
<div
className={className}
onClick={onClick}
style={{
width: sizeStr,
height: sizeStr,
borderRadius: '50%',
backgroundColor: getUserColor(username || 'U'),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontWeight: 600,
fontSize,
userSelect: 'none',
flexShrink: 0,
cursor: onClick ? 'pointer' : 'default',
...style,
}}
>
{(username || '?').substring(0, 1).toUpperCase()}
</div>
);
};
export default Avatar;

View File

@@ -39,10 +39,10 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
backgroundColor: 'hsl(240 calc(1*7.143%) 10.98% /1)', backgroundColor: 'var(--bg-primary)',
zIndex: 1000, zIndex: 1000,
display: 'flex', display: 'flex',
color: '#dcddde' color: 'var(--text-normal)'
}}> }}>
{/* Sidebar */} {/* Sidebar */}
<div style={{ <div style={{
@@ -57,7 +57,7 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
<div style={{ <div style={{
fontSize: '12px', fontSize: '12px',
fontWeight: '700', fontWeight: '700',
color: '#96989d', color: 'var(--text-muted)',
marginBottom: '6px', marginBottom: '6px',
textTransform: 'uppercase' textTransform: 'uppercase'
}}> }}>
@@ -69,8 +69,8 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
style={{ style={{
padding: '6px 10px', padding: '6px 10px',
borderRadius: '4px', borderRadius: '4px',
backgroundColor: activeTab === 'Overview' ? '#393c43' : 'transparent', backgroundColor: activeTab === 'Overview' ? 'var(--background-modifier-selected)' : 'transparent',
color: activeTab === 'Overview' ? '#fff' : '#b9bbbe', color: activeTab === 'Overview' ? 'var(--header-primary)' : 'var(--header-secondary)',
cursor: 'pointer', cursor: 'pointer',
marginBottom: '2px', marginBottom: '2px',
fontSize: '15px' fontSize: '15px'
@@ -79,7 +79,7 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
Overview Overview
</div> </div>
<div style={{ height: '1px', backgroundColor: '#3f4147', margin: '8px 0' }} /> <div style={{ height: '1px', backgroundColor: 'var(--border-subtle)', margin: '8px 0' }} />
<div <div
onClick={() => setActiveTab('Delete')} onClick={() => setActiveTab('Delete')}
@@ -102,18 +102,18 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
{/* Content */} {/* Content */}
<div style={{ flex: 1, padding: '60px 40px', maxWidth: '740px' }}> <div style={{ flex: 1, padding: '60px 40px', maxWidth: '740px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '20px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '20px' }}>
<h2 style={{ color: 'white', margin: 0 }}> <h2 style={{ color: 'var(--header-primary)', margin: 0 }}>
{activeTab === 'Delete' ? 'Delete Channel' : 'Overview'} {activeTab === 'Delete' ? 'Delete Channel' : 'Overview'}
</h2> </h2>
<button <button
onClick={onClose} onClick={onClose}
style={{ style={{
background: 'transparent', background: 'transparent',
border: '1px solid #b9bbbe', border: '1px solid var(--header-secondary)',
borderRadius: '50%', borderRadius: '50%',
width: '36px', width: '36px',
height: '36px', height: '36px',
color: '#b9bbbe', color: 'var(--header-secondary)',
cursor: 'pointer', cursor: 'pointer',
display: 'flex', allignItems: 'center', justifyContent: 'center' display: 'flex', allignItems: 'center', justifyContent: 'center'
}} }}
@@ -127,7 +127,7 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<label style={{ <label style={{
display: 'block', display: 'block',
color: '#b9bbbe', color: 'var(--header-secondary)',
fontSize: '12px', fontSize: '12px',
fontWeight: '700', fontWeight: '700',
textTransform: 'uppercase', textTransform: 'uppercase',
@@ -141,11 +141,11 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
style={{ style={{
width: '100%', width: '100%',
backgroundColor: '#202225', backgroundColor: 'var(--bg-tertiary)',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
padding: '10px', padding: '10px',
color: '#dcddde', color: 'var(--text-normal)',
fontSize: '16px', fontSize: '16px',
outline: 'none' outline: 'none'
}} }}
@@ -157,7 +157,7 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
onClick={handleSave} onClick={handleSave}
style={{ style={{
backgroundColor: '#3ba55c', backgroundColor: '#3ba55c',
color: 'white', color: 'var(--header-primary)',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
padding: '10px 24px', padding: '10px 24px',
@@ -173,16 +173,16 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
)} )}
{activeTab === 'Delete' && ( {activeTab === 'Delete' && (
<div style={{ backgroundColor: '#202225', padding: '16px', borderRadius: '8px', border: '1px solid #ed4245' }}> <div style={{ backgroundColor: 'var(--bg-tertiary)', padding: '16px', borderRadius: '8px', border: '1px solid #ed4245' }}>
<h3 style={{ color: 'white', marginTop: 0 }}>Are you sure?</h3> <h3 style={{ color: 'var(--header-primary)', marginTop: 0 }}>Are you sure?</h3>
<p style={{ color: '#b9bbbe' }}> <p style={{ color: 'var(--header-secondary)' }}>
Deleting <b>#{channel.name}</b> cannot be undone. All messages and keys will be lost forever. Deleting <b>#{channel.name}</b> cannot be undone. All messages and keys will be lost forever.
</p> </p>
<button <button
onClick={handleDelete} onClick={handleDelete}
style={{ style={{
backgroundColor: '#ed4245', backgroundColor: '#ed4245',
color: 'white', color: 'var(--header-primary)',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
padding: '10px 24px', padding: '10px 24px',

View File

@@ -7,8 +7,8 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { import {
GifIcon, GifIcon,
EmojieIcon,
StickerIcon, StickerIcon,
EmojieIcon,
EmojiesColored, EmojiesColored,
EmojiesGreyscale, EmojiesGreyscale,
EditIcon, EditIcon,
@@ -26,6 +26,11 @@ const fireIcon = getEmojiUrl('nature', 'fire');
const heartIcon = getEmojiUrl('symbols', 'heart'); const heartIcon = getEmojiUrl('symbols', 'heart');
const thumbsupIcon = getEmojiUrl('people', 'thumbsup'); const thumbsupIcon = getEmojiUrl('people', 'thumbsup');
import GifPicker from './GifPicker'; import GifPicker from './GifPicker';
import PinnedMessagesPanel from './PinnedMessagesPanel';
import Tooltip from './Tooltip';
import UserProfilePopup from './UserProfilePopup';
import Avatar from './Avatar';
import MentionMenu from './MentionMenu';
const metadataCache = new Map(); const metadataCache = new Map();
const attachmentCache = new Map(); const attachmentCache = new Map();
@@ -79,6 +84,20 @@ const formatEmojis = (text) => {
}); });
}; };
const filterMembersForMention = (members, query) => {
if (!members) return [];
const q = query.toLowerCase();
if (!q) return members;
const prefix = [];
const substring = [];
for (const m of members) {
const name = m.username.toLowerCase();
if (name.startsWith(q)) prefix.push(m);
else if (name.includes(q)) substring.push(m);
}
return [...prefix, ...substring];
};
const isNewDay = (current, previous) => { const isNewDay = (current, previous) => {
if (!previous) return true; if (!previous) return true;
return current.getDate() !== previous.getDate() return current.getDate() !== previous.getDate()
@@ -234,7 +253,7 @@ const Attachment = ({ metadata, onLoad, onImageClick }) => {
return () => { isMounted = false; }; return () => { isMounted = false; };
}, [metadata, onLoad]); }, [metadata, onLoad]);
if (loading) return <div style={{ color: '#b9bbbe', fontSize: '12px' }}>Downloading & Decrypting...</div>; if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>Downloading & Decrypting...</div>;
if (error) return <div style={{ color: '#ed4245', fontSize: '12px' }}>{error}</div>; if (error) return <div style={{ color: '#ed4245', fontSize: '12px' }}>{error}</div>;
if (metadata.mimeType.startsWith('image/')) { if (metadata.mimeType.startsWith('image/')) {
@@ -250,12 +269,12 @@ const Attachment = ({ metadata, onLoad, onImageClick }) => {
); );
} }
return ( return (
<div style={{ display: 'flex', alignItems: 'center', backgroundColor: '#2f3136', padding: '10px', borderRadius: '4px', maxWidth: '300px' }}> <div style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--embed-background)', padding: '10px', borderRadius: '4px', maxWidth: '300px' }}>
<span style={{ marginRight: '10px', fontSize: '24px' }}>📄</span> <span style={{ marginRight: '10px', fontSize: '24px' }}>📄</span>
<div style={{ overflow: 'hidden' }}> <div style={{ overflow: 'hidden' }}>
<div style={{ color: '#00b0f4', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{metadata.filename}</div> <div style={{ color: 'var(--text-link)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{metadata.filename}</div>
<div style={{ color: '#b9bbbe', fontSize: '12px' }}>{(metadata.size / 1024).toFixed(1)} KB</div> <div style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>{(metadata.size / 1024).toFixed(1)} KB</div>
<a href={url} download={metadata.filename} style={{ color: '#b9bbbe', fontSize: '12px', textDecoration: 'underline' }}>Download</a> <a href={url} download={metadata.filename} style={{ color: 'var(--header-secondary)', fontSize: '12px', textDecoration: 'underline' }}>Download</a>
</div> </div>
</div> </div>
); );
@@ -296,14 +315,14 @@ const PendingFilePreview = ({ file, onRemove }) => {
previewContent = ( previewContent = (
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px' }}>📄</div> <div style={{ fontSize: '24px' }}>📄</div>
<div style={{ fontSize: '10px', color: '#b9bbbe', marginTop: '4px', maxWidth: '90px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</div> <div style={{ fontSize: '10px', color: 'var(--header-secondary)', marginTop: '4px', maxWidth: '90px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</div>
</div> </div>
); );
} }
return ( return (
<div style={{ display: 'inline-flex', flexDirection: 'column', marginRight: '10px' }}> <div style={{ display: 'inline-flex', flexDirection: 'column', marginRight: '10px' }}>
<div style={{ position: 'relative', width: '200px', height: '200px', borderRadius: '8px', backgroundColor: '#2f3136', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}> <div style={{ position: 'relative', width: '200px', height: '200px', borderRadius: '8px', backgroundColor: 'var(--embed-background)', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
{previewContent} {previewContent}
<div style={{ position: 'absolute', top: '4px', right: '4px', display: 'flex', gap: '4px', padding: '4px' }}> <div style={{ position: 'absolute', top: '4px', right: '4px', display: 'flex', gap: '4px', padding: '4px' }}>
<ActionButton icon={SpoilerIcon} onClick={() => {}} /> <ActionButton icon={SpoilerIcon} onClick={() => {}} />
@@ -312,7 +331,7 @@ const PendingFilePreview = ({ file, onRemove }) => {
</div> </div>
</div> </div>
{preview && ( {preview && (
<div style={{ fontSize: '11px', color: '#b9bbbe', marginTop: '4px', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</div> <div style={{ fontSize: '11px', color: 'var(--header-secondary)', marginTop: '4px', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</div>
)} )}
</div> </div>
); );
@@ -339,27 +358,45 @@ const EmojiButton = ({ onClick, active }) => {
return `-${col * 24}px -${row * 24}px`; return `-${col * 24}px -${row * 24}px`;
}; };
return ( return (
<div className="chat-input-icon-btn" onClick={(e) => { e.stopPropagation(); if(onClick) onClick(); }} onMouseEnter={() => { setHovered(true); setBgPos(getRandomPos()); }} onMouseLeave={() => { setHovered(false); setBgPos(getRandomPos()); }} style={{ width: '24px', height: '24px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', marginLeft: '4px' }} title="Select Emoji"> <Tooltip text="Select Emoji" position="top">
<div className="chat-input-icon-btn" onClick={(e) => { e.stopPropagation(); if(onClick) onClick(); }} onMouseEnter={() => { setHovered(true); setBgPos(getRandomPos()); }} onMouseLeave={() => { setHovered(false); setBgPos(getRandomPos()); }} style={{ width: '24px', height: '24px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', marginLeft: '4px' }}>
<div style={{ width: '24px', height: '24px', backgroundImage: `url(${(hovered || active) ? EmojiesColored : EmojiesGreyscale})`, backgroundPosition: bgPos, backgroundSize: '480px 96px', backgroundRepeat: 'no-repeat' }} /> <div style={{ width: '24px', height: '24px', backgroundImage: `url(${(hovered || active) ? EmojiesColored : EmojiesGreyscale})`, backgroundPosition: bgPos, backgroundSize: '480px 96px', backgroundRepeat: 'no-repeat' }} />
</div> </div>
</Tooltip>
); );
}; };
const MessageToolbar = ({ onAddReaction, onEdit, onReply, onMore, isOwner }) => ( const MessageToolbar = ({ onAddReaction, onEdit, onReply, onMore, isOwner }) => (
<div style={{ position: 'absolute', top: '-40px', right: '16px', backgroundColor: 'color-mix(in oklab, hsl(240 calc(1*6.494%) 15.098% /1) 100%, #000 0%)', border: '1px solid color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.0392156862745098) 100%,hsl(0 0% 0% /0.0392156862745098) 0%)', borderRadius: '8px', boxShadow: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.0392156862745098) 100%,hsl(0 0% 0% /0.0392156862745098) 0%)', display: 'flex', alignItems: 'center', zIndex: 10, padding: '2px' }}> <div className="message-toolbar">
<IconButton onClick={() => onAddReaction('thumbsup')} title="Add Reaction" emoji={<ColoredIcon src={thumbsupIcon} size="20px" />} /> <Tooltip text="Thumbs Up" position="top">
<IconButton onClick={() => onAddReaction('heart')} title="Add Reaction" emoji={<ColoredIcon src={heartIcon} size="20px" />} /> <IconButton onClick={() => onAddReaction('thumbsup')} emoji={<ColoredIcon src={thumbsupIcon} size="20px" />} />
<IconButton onClick={() => onAddReaction('fire')} title="Add Reaction" emoji={<ColoredIcon src={fireIcon} size="20px" />} /> </Tooltip>
<Tooltip text="Heart" position="top">
<IconButton onClick={() => onAddReaction('heart')} emoji={<ColoredIcon src={heartIcon} size="20px" />} />
</Tooltip>
<Tooltip text="Fire" position="top">
<IconButton onClick={() => onAddReaction('fire')} emoji={<ColoredIcon src={fireIcon} size="20px" />} />
</Tooltip>
<div style={{ width: '1px', height: '24px', margin: '2px 4px', backgroundColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%)' }}></div> <div style={{ width: '1px', height: '24px', margin: '2px 4px', backgroundColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%)' }}></div>
<IconButton onClick={() => onAddReaction(null)} title="Add Reaction" emoji={<ColoredIcon src={EmojieIcon} color={ICON_COLOR_DEFAULT} size="20px" />} /> <Tooltip text="Add Reaction" position="top">
{isOwner && <IconButton onClick={onEdit} title="Edit" emoji={<ColoredIcon src={EditIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />} <IconButton onClick={() => onAddReaction(null)} emoji={<ColoredIcon src={EmojieIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
<IconButton onClick={onReply} title="Reply" emoji={<ColoredIcon src={ReplyIcon} color={ICON_COLOR_DEFAULT} size="20px" />} /> </Tooltip>
<IconButton onClick={onMore} title="More" emoji={<ColoredIcon src={MoreIcon} color={ICON_COLOR_DEFAULT} size="20px" />} /> {isOwner && (
<Tooltip text="Edit" position="top">
<IconButton onClick={onEdit} emoji={<ColoredIcon src={EditIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
</Tooltip>
)}
<Tooltip text="Reply" position="top">
<IconButton onClick={onReply} emoji={<ColoredIcon src={ReplyIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
</Tooltip>
<Tooltip text="More" position="top">
<IconButton onClick={onMore} emoji={<ColoredIcon src={MoreIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
</Tooltip>
</div> </div>
); );
const IconButton = ({ onClick, title, emoji }) => ( const IconButton = ({ onClick, emoji }) => (
<div onClick={(e) => { e.stopPropagation(); onClick(e); }} title={title} style={{ cursor: 'pointer', padding: '6px', fontSize: '16px', lineHeight: 1, color: '#b9bbbe', transition: 'background-color 0.1s' }} onMouseEnter={(e) => e.target.style.backgroundColor = '#40444b'} onMouseLeave={(e) => e.target.style.backgroundColor = 'transparent'}> <div onClick={(e) => { e.stopPropagation(); onClick(e); }} className="icon-button" style={{ cursor: 'pointer', padding: '6px', fontSize: '16px', lineHeight: 1, color: 'var(--header-secondary)', transition: 'background-color 0.1s' }}>
{emoji} {emoji}
</div> </div>
); );
@@ -380,20 +417,20 @@ const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner }) => {
}, [x, y]); }, [x, y]);
const MenuItem = ({ label, iconSrc, iconColor, onClick, danger }) => ( const MenuItem = ({ label, iconSrc, iconColor, onClick, danger }) => (
<div onClick={(e) => { e.stopPropagation(); onClick(); onClose(); }} style={{ display: 'flex', alignItems: 'center', padding: '10px 12px', cursor: 'pointer', fontSize: '14px', color: danger ? ICON_COLOR_DANGER : '#dcddde', justifyContent: 'space-between', whiteSpace: 'nowrap' }} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = danger ? 'color-mix(in oklab,hsl(355.636 calc(1*64.706%) 50% /0.0784313725490196) 100%,hsl(0 0% 0% /0.0784313725490196) 0%)' : 'hsl(240 calc(1*4%) 60.784% /0.0784313725490196)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}> <div onClick={(e) => { e.stopPropagation(); onClick(); onClose(); }} className={`context-menu-item ${danger ? 'context-menu-item-danger' : ''}`}>
<span>{label}</span> <span>{label}</span>
<div style={{ marginLeft: '12px' }}><ColoredIcon src={iconSrc} color={iconColor} size="18px" /></div> <div style={{ marginLeft: '12px' }}><ColoredIcon src={iconSrc} color={iconColor} size="18px" /></div>
</div> </div>
); );
return ( return (
<div ref={menuRef} style={{ position: 'fixed', top: pos.top, left: pos.left, backgroundColor: '#18191c', borderRadius: '4px', boxShadow: '0 8px 16px rgba(0,0,0,0.24)', zIndex: 9999, minWidth: '188px', padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: '2px' }} onClick={(e) => e.stopPropagation()}> <div ref={menuRef} className="context-menu" style={{ top: pos.top, left: pos.left }} onClick={(e) => e.stopPropagation()}>
<MenuItem label="Add Reaction" iconSrc={EmojieIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('reaction')} /> <MenuItem label="Add Reaction" iconSrc={EmojieIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('reaction')} />
{isOwner && <MenuItem label="Edit Message" iconSrc={EditIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('edit')} />} {isOwner && <MenuItem label="Edit Message" iconSrc={EditIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('edit')} />}
<MenuItem label="Reply" iconSrc={ReplyIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('reply')} /> <MenuItem label="Reply" iconSrc={ReplyIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('reply')} />
<div style={{ height: '1px', backgroundColor: '#36393f', margin: '4px 0' }} /> <div className="context-menu-separator" />
<MenuItem label="Pin Message" iconSrc={PinIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('pin')} /> <MenuItem label="Pin Message" iconSrc={PinIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('pin')} />
<div style={{ height: '1px', backgroundColor: '#36393f', margin: '4px 0' }} /> <div className="context-menu-separator" />
{isOwner && <MenuItem label="Delete Message" iconSrc={DeleteIcon} iconColor={ICON_COLOR_DANGER} danger onClick={() => onInteract('delete')} />} {isOwner && <MenuItem label="Delete Message" iconSrc={DeleteIcon} iconColor={ICON_COLOR_DANGER} danger onClick={() => onInteract('delete')} />}
</div> </div>
); );
@@ -440,7 +477,17 @@ const parseAttachment = (content) => {
} }
}; };
const ChatArea = ({ channelId, channelName, username, channelKey, userId: currentUserId }) => { const parseSystemMessage = (content) => {
if (!content || !content.startsWith('{')) return null;
try {
const parsed = JSON.parse(content);
return parsed.type === 'system' ? parsed : null;
} catch (e) {
return null;
}
};
const ChatArea = ({ channelId, channelName, channelType, username, channelKey, userId: currentUserId, showMembers, onToggleMembers, onOpenDM, showPinned, onTogglePinned }) => {
const [decryptedMessages, setDecryptedMessages] = useState([]); const [decryptedMessages, setDecryptedMessages] = useState([]);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [zoomedImage, setZoomedImage] = useState(null); const [zoomedImage, setZoomedImage] = useState(null);
@@ -452,6 +499,12 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
const [hoveredMessageId, setHoveredMessageId] = useState(null); const [hoveredMessageId, setHoveredMessageId] = useState(null);
const [contextMenu, setContextMenu] = useState(null); const [contextMenu, setContextMenu] = useState(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [replyingTo, setReplyingTo] = useState(null);
const [editingMessage, setEditingMessage] = useState(null);
const [editInput, setEditInput] = useState('');
const [profilePopup, setProfilePopup] = useState(null);
const [mentionQuery, setMentionQuery] = useState(null);
const [mentionIndex, setMentionIndex] = useState(0);
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null); const messagesContainerRef = useRef(null);
@@ -470,6 +523,8 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
const convex = useConvex(); const convex = useConvex();
const members = useQuery(api.members.getChannelMembers, channelId ? { channelId } : "skip") || [];
const { results: rawMessages, status, loadMore } = usePaginatedQuery( const { results: rawMessages, status, loadMore } = usePaginatedQuery(
api.messages.list, api.messages.list,
channelId ? { channelId, userId: currentUserId || undefined } : "skip", channelId ? { channelId, userId: currentUserId || undefined } : "skip",
@@ -482,6 +537,9 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
) || []; ) || [];
const sendMessageMutation = useMutation(api.messages.send); const sendMessageMutation = useMutation(api.messages.send);
const editMessageMutation = useMutation(api.messages.edit);
const pinMessageMutation = useMutation(api.messages.pin);
const deleteMessageMutation = useMutation(api.messages.remove);
const addReaction = useMutation(api.reactions.add); const addReaction = useMutation(api.reactions.add);
const removeReaction = useMutation(api.reactions.remove); const removeReaction = useMutation(api.reactions.remove);
const startTypingMutation = useMutation(api.typing.startTyping); const startTypingMutation = useMutation(api.typing.startTyping);
@@ -509,6 +567,20 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
} }
}; };
const decryptReplyContent = async (ciphertext, nonce) => {
try {
if (!ciphertext || !nonce || !channelKey) return null;
const TAG_LENGTH = 32;
const tag = ciphertext.slice(-TAG_LENGTH);
const content = ciphertext.slice(0, -TAG_LENGTH);
const decrypted = await window.cryptoAPI.decryptData(content, channelKey, nonce, tag);
if (decrypted.startsWith('{')) return '[Attachment]';
return decrypted.length > 100 ? decrypted.substring(0, 100) + '...' : decrypted;
} catch {
return '[Encrypted]';
}
};
const verifyMessage = async (msg) => { const verifyMessage = async (msg) => {
if (!msg.signature || !msg.public_signing_key) return false; if (!msg.signature || !msg.public_signing_key) return false;
try { return await window.cryptoAPI.verifySignature(msg.public_signing_key, msg.ciphertext, msg.signature); } try { return await window.cryptoAPI.verifySignature(msg.public_signing_key, msg.ciphertext, msg.signature); }
@@ -525,27 +597,36 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
let cancelled = false; let cancelled = false;
const processMessages = async () => { const processMessages = async () => {
// Decrypt only messages not already in cache const needsDecryption = rawMessages.filter(msg => {
const needsDecryption = rawMessages.filter(msg => !cache.has(msg.id)); const cached = cache.get(msg.id);
if (!cached) return true;
// Re-decrypt if reply nonce is now available but reply wasn't decrypted
if (msg.replyToNonce && msg.replyToContent && cached.decryptedReply === null) return true;
return false;
});
if (needsDecryption.length > 0) { if (needsDecryption.length > 0) {
await Promise.all(needsDecryption.map(async (msg) => { await Promise.all(needsDecryption.map(async (msg) => {
const content = await decryptMessage(msg); const content = await decryptMessage(msg);
const isVerified = await verifyMessage(msg); const isVerified = await verifyMessage(msg);
let decryptedReply = null;
if (msg.replyToContent && msg.replyToNonce) {
decryptedReply = await decryptReplyContent(msg.replyToContent, msg.replyToNonce);
}
if (!cancelled) { if (!cancelled) {
cache.set(msg.id, { content, isVerified }); cache.set(msg.id, { content, isVerified, decryptedReply });
} }
})); }));
} }
if (cancelled) return; if (cancelled) return;
// Build full chronological array (rawMessages is newest-first, reverse for display)
const processed = [...rawMessages].reverse().map(msg => { const processed = [...rawMessages].reverse().map(msg => {
const cached = cache.get(msg.id); const cached = cache.get(msg.id);
return { return {
...msg, ...msg,
content: cached?.content ?? '[Decrypting...]', content: cached?.content ?? '[Decrypting...]',
isVerified: cached?.isVerified ?? false, isVerified: cached?.isVerified ?? false,
decryptedReply: cached?.decryptedReply ?? null,
}; };
}); });
setDecryptedMessages(processed); setDecryptedMessages(processed);
@@ -555,15 +636,19 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [rawMessages, channelKey]); }, [rawMessages, channelKey]);
// Clear decryption cache and reset scroll state on channel/key change
useEffect(() => { useEffect(() => {
decryptionCacheRef.current.clear(); decryptionCacheRef.current.clear();
setDecryptedMessages([]); setDecryptedMessages([]);
isInitialLoadRef.current = true; isInitialLoadRef.current = true;
prevResultsLengthRef.current = 0; prevResultsLengthRef.current = 0;
setReplyingTo(null);
setEditingMessage(null);
setMentionQuery(null);
onTogglePinned();
}, [channelId, channelKey]); }, [channelId, channelKey]);
const typingUsers = typingData.filter(t => t.username !== username); const typingUsers = typingData.filter(t => t.username !== username);
const filteredMentionMembers = mentionQuery !== null ? filterMembersForMention(members, mentionQuery) : [];
const scrollToBottom = useCallback((force = false) => { const scrollToBottom = useCallback((force = false) => {
if (force) { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); return; } if (force) { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); return; }
@@ -576,12 +661,10 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
} }
}, []); }, []);
// Scroll management via useLayoutEffect (fires before paint)
useLayoutEffect(() => { useLayoutEffect(() => {
const container = messagesContainerRef.current; const container = messagesContainerRef.current;
if (!container || decryptedMessages.length === 0) return; if (!container || decryptedMessages.length === 0) return;
// Initial load — instant scroll to bottom (no animation)
if (isInitialLoadRef.current) { if (isInitialLoadRef.current) {
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
isInitialLoadRef.current = false; isInitialLoadRef.current = false;
@@ -589,7 +672,6 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
return; return;
} }
// Load more (older messages prepended) — preserve scroll position
if (isLoadingMoreRef.current) { if (isLoadingMoreRef.current) {
const newScrollHeight = container.scrollHeight; const newScrollHeight = container.scrollHeight;
const heightDifference = newScrollHeight - prevScrollHeightRef.current; const heightDifference = newScrollHeight - prevScrollHeightRef.current;
@@ -599,7 +681,6 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
return; return;
} }
// User sent a message — force scroll to bottom
if (userSentMessageRef.current) { if (userSentMessageRef.current) {
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
userSentMessageRef.current = false; userSentMessageRef.current = false;
@@ -607,7 +688,6 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
return; return;
} }
// Real-time new message — auto-scroll if near bottom
const currentLen = rawMessages?.length || 0; const currentLen = rawMessages?.length || 0;
const prevLen = prevResultsLengthRef.current; const prevLen = prevResultsLengthRef.current;
if (currentLen > prevLen && (currentLen - prevLen) <= 5) { if (currentLen > prevLen && (currentLen - prevLen) <= 5) {
@@ -620,7 +700,6 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
prevResultsLengthRef.current = currentLen; prevResultsLengthRef.current = currentLen;
}, [decryptedMessages, rawMessages?.length]); }, [decryptedMessages, rawMessages?.length]);
// IntersectionObserver to trigger loadMore when scrolling near the top
useEffect(() => { useEffect(() => {
const sentinel = topSentinelRef.current; const sentinel = topSentinelRef.current;
const container = messagesContainerRef.current; const container = messagesContainerRef.current;
@@ -692,6 +771,47 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
const newRange = document.createRange(); newRange.setStart(afterNode, 0); newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange); const newRange = document.createRange(); newRange.setStart(afterNode, 0); newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange);
}; };
const checkMentionTrigger = () => {
if (!inputDivRef.current) return;
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
const node = range.startContainer;
if (node.nodeType !== Node.TEXT_NODE) { setMentionQuery(null); return; }
const content = node.textContent.substring(0, range.startOffset);
const match = content.match(/(?:^|\s)@(\w*)$/);
if (match) {
setMentionQuery(match[1]);
setMentionIndex(0);
} else {
setMentionQuery(null);
}
};
const insertMention = (member) => {
if (!inputDivRef.current) return;
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
const node = range.startContainer;
if (node.nodeType !== Node.TEXT_NODE) return;
const content = node.textContent.substring(0, range.startOffset);
const match = content.match(/(?:^|\s)@(\w*)$/);
if (!match) return;
const matchStart = match.index + (match[0].startsWith(' ') ? 1 : 0);
const before = node.textContent.substring(0, matchStart);
const after = node.textContent.substring(range.startOffset);
node.textContent = before + '@' + member.username + ' ' + after;
const newOffset = before.length + 1 + member.username.length + 1;
const newRange = document.createRange();
newRange.setStart(node, newOffset);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
setMentionQuery(null);
setInput(inputDivRef.current.textContent);
};
const processFile = (file) => { setPendingFiles(prev => [...prev, file]); }; const processFile = (file) => { setPendingFiles(prev => [...prev, file]); };
const handleFileSelect = (e) => { if (e.target.files && e.target.files.length > 0) Array.from(e.target.files).forEach(processFile); }; const handleFileSelect = (e) => { if (e.target.files && e.target.files.length > 0) Array.from(e.target.files).forEach(processFile); };
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); if (!isDragging) setIsDragging(true); }; const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); if (!isDragging) setIsDragging(true); };
@@ -726,7 +846,7 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
await sendMessage(JSON.stringify(metadata)); await sendMessage(JSON.stringify(metadata));
}; };
const sendMessage = async (contentString) => { const sendMessage = async (contentString, replyToId) => {
try { try {
if (!channelKey) { alert("Cannot send: Missing Encryption Key"); return; } if (!channelKey) { alert("Cannot send: Missing Encryption Key"); return; }
const { content: encryptedContent, iv, tag } = await window.cryptoAPI.encryptData(contentString, channelKey); const { content: encryptedContent, iv, tag } = await window.cryptoAPI.encryptData(contentString, channelKey);
@@ -735,14 +855,17 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
const signingKey = sessionStorage.getItem('signingKey'); const signingKey = sessionStorage.getItem('signingKey');
if (!senderId || !signingKey) return; if (!senderId || !signingKey) return;
await sendMessageMutation({ const args = {
channelId, channelId,
senderId, senderId,
ciphertext, ciphertext,
nonce: iv, nonce: iv,
signature: await window.cryptoAPI.signMessage(signingKey, ciphertext), signature: await window.cryptoAPI.signMessage(signingKey, ciphertext),
keyVersion: 1 keyVersion: 1
}); };
if (replyToId) args.replyTo = replyToId;
await sendMessageMutation(args);
} catch (err) { } catch (err) {
console.error('Send error:', err); console.error('Send error:', err);
} }
@@ -772,15 +895,18 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
setUploading(true); setUploading(true);
userSentMessageRef.current = true; userSentMessageRef.current = true;
const replyId = replyingTo?.messageId;
try { try {
for (const file of pendingFiles) await uploadAndSendFile(file); for (const file of pendingFiles) await uploadAndSendFile(file);
setPendingFiles([]); setPendingFiles([]);
if (messageContent) { if (messageContent) {
await sendMessage(messageContent); await sendMessage(messageContent, replyId);
if (inputDivRef.current) inputDivRef.current.innerHTML = ''; if (inputDivRef.current) inputDivRef.current.innerHTML = '';
setInput(''); setHasImages(false); setInput(''); setHasImages(false);
clearTypingState(); clearTypingState();
} }
setReplyingTo(null);
setMentionQuery(null);
} catch (err) { } catch (err) {
console.error("Error sending message/files:", err); console.error("Error sending message/files:", err);
alert("Failed to send message/files"); alert("Failed to send message/files");
@@ -790,8 +916,38 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
} }
}; };
const handleEditSave = async () => {
if (!editingMessage || !editInput.trim()) {
setEditingMessage(null);
return;
}
try {
const { content: encryptedContent, iv, tag } = await window.cryptoAPI.encryptData(editInput, channelKey);
const ciphertext = encryptedContent + tag;
const signingKey = sessionStorage.getItem('signingKey');
await editMessageMutation({
id: editingMessage.id,
ciphertext,
nonce: iv,
signature: await window.cryptoAPI.signMessage(signingKey, ciphertext),
});
decryptionCacheRef.current.delete(editingMessage.id);
setEditingMessage(null);
setEditInput('');
} catch (err) {
console.error('Edit error:', err);
}
};
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
if (mentionQuery !== null && filteredMentionMembers.length > 0) {
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => (i + 1) % filteredMentionMembers.length); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(i => (i - 1 + filteredMentionMembers.length) % filteredMentionMembers.length); return; }
if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') { e.preventDefault(); insertMention(filteredMentionMembers[mentionIndex]); return; }
if (e.key === 'Escape') { e.preventDefault(); setMentionQuery(null); return; }
}
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(e); } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(e); }
if (e.key === 'Escape' && replyingTo) { setReplyingTo(null); return; }
if (e.key === 'Backspace' && inputDivRef.current) { if (e.key === 'Backspace' && inputDivRef.current) {
const sel = window.getSelection(); const sel = window.getSelection();
if (sel.rangeCount > 0 && sel.isCollapsed) { if (sel.rangeCount > 0 && sel.isCollapsed) {
@@ -809,6 +965,11 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
} }
}; };
const handleEditKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleEditSave(); }
if (e.key === 'Escape') { setEditingMessage(null); setEditInput(''); }
};
const handleReactionClick = async (messageId, emoji, hasMyReaction) => { const handleReactionClick = async (messageId, emoji, hasMyReaction) => {
if (!currentUserId) return; if (!currentUserId) return;
if (hasMyReaction) { if (hasMyReaction) {
@@ -826,7 +987,53 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
} }
}; };
const handleContextInteract = (action, messageId) => {
const msg = decryptedMessages.find(m => m.id === messageId);
if (!msg) return;
switch (action) {
case 'reply':
setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) });
break;
case 'edit':
setEditingMessage({ id: msg.id, content: msg.content });
setEditInput(msg.content);
break;
case 'pin':
pinMessageMutation({ id: msg.id, pinned: !msg.pinned });
break;
case 'delete':
deleteMessageMutation({ id: msg.id });
break;
case 'reaction':
addReaction({ messageId: msg.id, userId: currentUserId, emoji: 'heart' });
break;
}
setContextMenu(null);
};
const scrollToMessage = (messageId) => {
const el = document.getElementById(`msg-${messageId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('message-highlight');
setTimeout(() => el.classList.remove('message-highlight'), 2000);
}
};
const renderMessageContent = (msg) => { const renderMessageContent = (msg) => {
const systemMsg = parseSystemMessage(msg.content);
if (systemMsg) {
return (
<div className="system-message">
<svg width="16" height="16" viewBox="0 0 24 24" fill="#3ba55c" style={{ marginRight: '8px', flexShrink: 0 }}>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
<span style={{ fontStyle: 'italic', color: 'var(--header-secondary)' }}>{systemMsg.text || 'System event'}</span>
</div>
);
}
const attachmentMetadata = parseAttachment(msg.content); const attachmentMetadata = parseAttachment(msg.content);
if (attachmentMetadata) { if (attachmentMetadata) {
return <Attachment metadata={attachmentMetadata} onLoad={scrollToBottom} onImageClick={setZoomedImage} />; return <Attachment metadata={attachmentMetadata} onLoad={scrollToBottom} onImageClick={setZoomedImage} />;
@@ -854,18 +1061,30 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
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={() => handleReactionClick(msg.id, emojiName, data.me)} style={{ display: 'flex', alignItems: 'center', backgroundColor: data.me ? 'rgba(88, 101, 242, 0.15)' : '#2f3136', border: data.me ? '1px solid #5865F2' : '1px solid transparent', borderRadius: '8px', padding: '2px 6px', cursor: 'pointer', gap: '4px' }}> <div key={emojiName} onClick={() => handleReactionClick(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 : '#b9bbbe'} /> <ColoredIcon src={getReactionIcon(emojiName)} size="16px" color={data.me ? null : 'var(--header-secondary)'} />
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : '#b9bbbe', fontWeight: 600 }}>{data.count}</span> <span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : 'var(--header-secondary)', fontWeight: 600 }}>{data.count}</span>
</div> </div>
))} ))}
</div> </div>
); );
}; };
const isDM = channelType === 'dm';
const placeholderText = isDM ? `Message @${channelName || 'user'}` : `Message #${channelName || channelId}`;
return ( return (
<div className="chat-area" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} style={{ position: 'relative' }}> <div className="chat-area" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} style={{ position: 'relative' }}>
{isDragging && <DragOverlay />} {isDragging && <DragOverlay />}
<PinnedMessagesPanel
channelId={channelId}
visible={showPinned}
onClose={onTogglePinned}
channelKey={channelKey}
onJumpToMessage={scrollToMessage}
/>
<div className="messages-list" ref={messagesContainerRef}> <div className="messages-list" ref={messagesContainerRef}>
<div className="messages-content-wrapper"> <div className="messages-content-wrapper">
<div ref={topSentinelRef} style={{ height: '1px', width: '100%' }} /> <div ref={topSentinelRef} style={{ height: '1px', width: '100%' }} />
@@ -876,9 +1095,16 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
)} )}
{status === 'Exhausted' && decryptedMessages.length > 0 && ( {status === 'Exhausted' && decryptedMessages.length > 0 && (
<div className="channel-beginning"> <div className="channel-beginning">
<div className="channel-beginning-icon">#</div> <div className="channel-beginning-icon">{isDM ? '@' : '#'}</div>
<h1 className="channel-beginning-title">Welcome to #{channelName}</h1> <h1 className="channel-beginning-title">
<p className="channel-beginning-subtitle">This is the start of the #{channelName} channel.</p> {isDM ? `${channelName}` : `Welcome to #${channelName}`}
</h1>
<p className="channel-beginning-subtitle">
{isDM
? `This is the beginning of your direct message history with ${channelName}.`
: `This is the start of the #${channelName} channel.`
}
</p>
</div> </div>
)} )}
{status === 'LoadingFirstPage' && ( {status === 'LoadingFirstPage' && (
@@ -892,18 +1118,39 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
const isMentioned = msg.content && msg.content.includes(`@${username}`); const isMentioned = msg.content && msg.content.includes(`@${username}`);
const userColor = getUserColor(msg.username || 'Unknown'); const userColor = getUserColor(msg.username || 'Unknown');
const isOwner = msg.username === username; const isOwner = msg.username === username;
const isEditing = editingMessage?.id === msg.id;
const prevMsg = idx > 0 ? decryptedMessages[idx - 1] : null; const prevMsg = idx > 0 ? decryptedMessages[idx - 1] : null;
const isGrouped = prevMsg const isGrouped = prevMsg
&& prevMsg.username === msg.username && prevMsg.username === msg.username
&& !isNewDay(currentDate, previousDate) && !isNewDay(currentDate, previousDate)
&& (currentDate - new Date(prevMsg.created_at)) < 60000; && (currentDate - new Date(prevMsg.created_at)) < 60000
&& !msg.replyToId;
return ( return (
<React.Fragment key={msg.id || idx}> <React.Fragment key={msg.id || idx}>
{isNewDay(currentDate, previousDate) && <div className="date-divider"><span>{currentDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}</span></div>} {isNewDay(currentDate, previousDate) && <div className="date-divider"><span>{currentDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}</span></div>}
<div className={`message-item${isGrouped ? ' message-grouped' : ''}`} style={isMentioned ? { background: 'color-mix(in oklab,hsl(36.894 calc(1*100%) 31.569% /0.0784313725490196) 100%,hsl(0 0% 0% /0.0784313725490196) 0%)', position: 'relative' } : { position: 'relative' }} onMouseEnter={() => setHoveredMessageId(msg.id)} onMouseLeave={() => setHoveredMessageId(null)} onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner }); }}> <div
id={`msg-${msg.id}`}
className={`message-item${isGrouped ? ' message-grouped' : ''}`}
style={isMentioned ? { background: 'color-mix(in oklab,hsl(36.894 calc(1*100%) 31.569% /0.0784313725490196) 100%,hsl(0 0% 0% /0.0784313725490196) 0%)', position: 'relative' } : { position: 'relative' }}
onMouseEnter={() => setHoveredMessageId(msg.id)}
onMouseLeave={() => setHoveredMessageId(null)}
onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner }); }}
>
{isMentioned && <div style={{ background: 'color-mix(in oklab, hsl(34 calc(1*50.847%) 53.725% /1) 100%, #000 0%)', bottom: 0, display: 'block', left: 0, pointerEvents: 'none', position: 'absolute', top: 0, width: '2px' }} />} {isMentioned && <div style={{ background: 'color-mix(in oklab, hsl(34 calc(1*50.847%) 53.725% /1) 100%, #000 0%)', bottom: 0, display: 'block', left: 0, pointerEvents: 'none', position: 'absolute', top: 0, width: '2px' }} />}
{msg.replyToId && msg.replyToUsername && (
<div className="message-reply-context" onClick={() => scrollToMessage(msg.replyToId)}>
<div className="reply-spine" />
<Avatar username={msg.replyToUsername} avatarUrl={msg.replyToAvatarUrl} size={16} className="reply-avatar" />
<span className="reply-author" style={{ color: getUserColor(msg.replyToUsername) }}>
@{msg.replyToUsername}
</span>
<span className="reply-text">{msg.decryptedReply || '[Encrypted]'}</span>
</div>
)}
{isGrouped ? ( {isGrouped ? (
<div className="message-avatar-wrapper grouped-timestamp-wrapper"> <div className="message-avatar-wrapper grouped-timestamp-wrapper">
<span className="grouped-timestamp"> <span className="grouped-timestamp">
@@ -912,29 +1159,56 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
</div> </div>
) : ( ) : (
<div className="message-avatar-wrapper"> <div className="message-avatar-wrapper">
<div className="message-avatar" style={{ backgroundColor: userColor }}> <Avatar
{(msg.username || '?').substring(0, 1).toUpperCase()} username={msg.username}
</div> avatarUrl={msg.avatarUrl}
size={40}
className="message-avatar"
style={{ cursor: 'pointer' }}
onClick={(e) => setProfilePopup({ userId: msg.sender_id, username: msg.username, avatarUrl: msg.avatarUrl, position: { x: e.clientX, y: e.clientY } })}
/>
</div> </div>
)} )}
<div className="message-body"> <div className="message-body">
{!isGrouped && ( {!isGrouped && (
<div className="message-header"> <div className="message-header">
<span className="username" style={{ color: userColor }}>{msg.username || 'Unknown'}</span> <span
className="username"
style={{ color: userColor, cursor: 'pointer' }}
onClick={(e) => setProfilePopup({ userId: msg.sender_id, username: msg.username, avatarUrl: msg.avatarUrl, position: { x: e.clientX, y: e.clientY } })}
>
{msg.username || 'Unknown'}
</span>
{!msg.isVerified && <span className="verification-failed" title="Signature Verification Failed!"><svg aria-hidden="true" role="img" width="16" height="16" viewBox="0 0 24 24" fill="var(--danger)"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></svg></span>} {!msg.isVerified && <span className="verification-failed" title="Signature Verification Failed!"><svg aria-hidden="true" role="img" width="16" height="16" viewBox="0 0 24 24" fill="var(--danger)"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></svg></span>}
<span className="timestamp">{currentDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span> <span className="timestamp">{currentDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span>
</div> </div>
)} )}
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
{isEditing ? (
<div className="message-editing">
<textarea
className="message-edit-textarea"
value={editInput}
onChange={(e) => setEditInput(e.target.value)}
onKeyDown={handleEditKeyDown}
autoFocus
/>
<div className="message-edit-hint">
escape to <span onClick={() => { setEditingMessage(null); setEditInput(''); }}>cancel</span> · enter to <span onClick={handleEditSave}>save</span>
</div>
</div>
) : (
<div className="message-content"> <div className="message-content">
{renderMessageContent(msg)} {renderMessageContent(msg)}
{msg.editedAt && <span className="edited-indicator">(edited)</span>}
{renderReactions(msg)} {renderReactions(msg)}
</div> </div>
{hoveredMessageId === msg.id && ( )}
{hoveredMessageId === msg.id && !isEditing && (
<MessageToolbar isOwner={isOwner} <MessageToolbar isOwner={isOwner}
onAddReaction={(emoji) => { addReaction({ messageId: msg.id, userId: currentUserId, emoji: emoji || 'heart' }); }} onAddReaction={(emoji) => { addReaction({ messageId: msg.id, userId: currentUserId, emoji: emoji || 'heart' }); }}
onEdit={() => console.log('Edit', msg.id)} onEdit={() => { setEditingMessage({ id: msg.id, content: msg.content }); setEditInput(msg.content); }}
onReply={() => console.log('Reply', msg.id)} onReply={() => setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) })}
onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner }); }} onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner }); }}
/> />
)} )}
@@ -947,17 +1221,36 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
</div> </div>
{contextMenu && <MessageContextMenu x={contextMenu.x} y={contextMenu.y} isOwner={contextMenu.isOwner} onClose={() => setContextMenu(null)} onInteract={(action) => { console.log('Context Action:', action, contextMenu.messageId); setContextMenu(null); }} />} {contextMenu && <MessageContextMenu x={contextMenu.x} y={contextMenu.y} isOwner={contextMenu.isOwner} onClose={() => setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
<form className="chat-input-form" onSubmit={handleSend} style={{ position: 'relative' }}> <form className="chat-input-form" onSubmit={handleSend} style={{ position: 'relative' }}>
{mentionQuery !== null && filteredMentionMembers.length > 0 && (
<MentionMenu
members={filteredMentionMembers}
selectedIndex={mentionIndex}
onSelect={insertMention}
onHover={setMentionIndex}
/>
)}
{typingUsers.length > 0 && ( {typingUsers.length > 0 && (
<div style={{ position: 'absolute', top: '-24px', left: '0', padding: '0 8px', display: 'flex', alignItems: 'center', gap: '6px', color: '#dbdee1', fontSize: '12px', fontWeight: 'bold', pointerEvents: 'none' }}> <div style={{ position: 'absolute', top: '-24px', left: '0', padding: '0 8px', display: 'flex', alignItems: 'center', gap: '6px', color: '#dbdee1', fontSize: '12px', fontWeight: 'bold', pointerEvents: 'none' }}>
<ColoredIcon src={TypingIcon} size="24px" color="#dbdee1" /> <ColoredIcon src={TypingIcon} size="24px" color="#dbdee1" />
<span>{typingUsers.map(t => t.username).join(', ')} is typing...</span> <span>{typingUsers.map(t => t.username).join(', ')} is typing...</span>
</div> </div>
)} )}
{replyingTo && (
<div className="reply-preview-bar">
<div className="reply-preview-content">
Replying to <strong>{replyingTo.username}</strong>
<span className="reply-preview-text">{replyingTo.content}</span>
</div>
<button className="reply-preview-close" onClick={() => setReplyingTo(null)}>&times;</button>
</div>
)}
{pendingFiles.length > 0 && ( {pendingFiles.length > 0 && (
<div style={{ display: 'flex', padding: '10px 16px 0', overflowX: 'auto', backgroundColor: 'color-mix(in oklab, hsl(240 calc(1*5.882%) 13.333% /1) 100%, #000 0%)', borderRadius: '8px 8px 0 0' }}> <div style={{ display: 'flex', padding: '10px 16px 0', overflowX: 'auto', backgroundColor: 'var(--channeltextarea-background)', borderRadius: '8px 8px 0 0' }}>
{pendingFiles.map((file, idx) => <PendingFilePreview key={idx} file={file} onRemove={(f) => setPendingFiles(prev => prev.filter(item => item !== f))} />)} {pendingFiles.map((file, idx) => <PendingFilePreview key={idx} file={file} onRemove={(f) => setPendingFiles(prev => prev.filter(item => item !== f))} />)}
</div> </div>
)} )}
@@ -971,11 +1264,20 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
onMouseUp={saveSelection} onMouseUp={saveSelection}
onKeyUp={saveSelection} onKeyUp={saveSelection}
onInput={(e) => { onInput={(e) => {
setInput(e.currentTarget.textContent); const textContent = e.currentTarget.textContent;
setInput(textContent);
setHasImages(e.currentTarget.querySelectorAll('img').length > 0); setHasImages(e.currentTarget.querySelectorAll('img').length > 0);
// Clean up browser artifacts (residual <br>) when content is fully erased
if (!textContent && !e.currentTarget.querySelectorAll('img').length) {
e.currentTarget.innerHTML = '';
setIsMultiline(false);
} else {
const text = e.currentTarget.innerText; const text = e.currentTarget.innerText;
setIsMultiline(text.includes('\n') || e.currentTarget.scrollHeight > 50); setIsMultiline(text.includes('\n') || e.currentTarget.scrollHeight > 50);
}
checkTypedEmoji(); checkTypedEmoji();
checkMentionTrigger();
const now = Date.now(); const now = Date.now();
if (now - lastTypingEmitRef.current > 2000 && currentUserId && channelId) { if (now - lastTypingEmitRef.current > 2000 && currentUserId && channelId) {
startTypingMutation({ channelId, userId: currentUserId, username }).catch(() => {}); startTypingMutation({ channelId, userId: currentUserId, username }).catch(() => {});
@@ -988,17 +1290,23 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
}, 3000); }, 3000);
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
style={{ flex: 1, backgroundColor: 'transparent', border: 'none', color: '#dcddde', fontSize: '16px', marginTop: '20px', marginLeft: '6px', minHeight: '44px', maxHeight: '200px', overflowY: 'auto', outline: 'none', whiteSpace: 'pre-wrap', wordBreak: 'break-word', lineHeight: '1.375rem', marginBottom: isMultiline ? '20px' : '0px' }} style={{ flex: 1, backgroundColor: 'transparent', border: 'none', color: 'var(--text-normal)', fontSize: '16px', marginTop: '20px', marginLeft: '6px', minHeight: '44px', maxHeight: '200px', overflowY: 'auto', outline: 'none', whiteSpace: 'pre-wrap', wordBreak: 'break-word', lineHeight: '1.375rem', marginBottom: isMultiline ? '20px' : '0px' }}
/> />
{!input && !hasImages && <div style={{ position: 'absolute', left: '70px', color: '#72767d', pointerEvents: 'none', userSelect: 'none' }}>Message #{channelName || channelId}</div>} {!input && !hasImages && <div style={{ position: 'absolute', left: '70px', color: 'var(--text-muted)', pointerEvents: 'none', userSelect: 'none' }}>{placeholderText}</div>}
<div className="chat-input-icons" style={{ position: 'relative' }}> <div className="chat-input-icons" style={{ position: 'relative' }}>
<button type="button" className="chat-input-icon-btn" title="GIF" onClick={(e) => { e.stopPropagation(); togglePicker('GIFs'); }}> <Tooltip text="GIF" position="top">
<button type="button" className="chat-input-icon-btn" onClick={(e) => { e.stopPropagation(); togglePicker('GIFs'); }}>
<ColoredIcon src={GifIcon} color={pickerTab === 'GIFs' ? '#5865f2' : ICON_COLOR_DEFAULT} size="24px" /> <ColoredIcon src={GifIcon} color={pickerTab === 'GIFs' ? '#5865f2' : ICON_COLOR_DEFAULT} size="24px" />
</button> </button>
</Tooltip>
<Tooltip text="Stickers" position="top">
<button type="button" className="chat-input-icon-btn" onClick={(e) => { e.stopPropagation(); togglePicker('Stickers'); }}>
<ColoredIcon src={StickerIcon} color={pickerTab === 'Stickers' ? '#5865f2' : ICON_COLOR_DEFAULT} size="24px" />
</button>
</Tooltip>
{showGifPicker && ( {showGifPicker && (
<GifPicker onSelect={(data) => { if (typeof data === 'string') { sendMessage(data); setPickerTab(null); } else { insertEmoji(data); setPickerTab(null); } }} onClose={() => setPickerTab(null)} currentTab={pickerTab} onTabChange={setPickerTab} /> <GifPicker onSelect={(data) => { if (typeof data === 'string') { sendMessage(data); setPickerTab(null); } else { insertEmoji(data); setPickerTab(null); } }} onClose={() => setPickerTab(null)} currentTab={pickerTab} onTabChange={setPickerTab} />
)} )}
<button type="button" className="chat-input-icon-btn" title="Sticker"><ColoredIcon src={StickerIcon} color={ICON_COLOR_DEFAULT} size="24px" /></button>
<EmojiButton active={pickerTab === 'Emoji'} onClick={() => togglePicker('Emoji')} /> <EmojiButton active={pickerTab === 'Emoji'} onClick={() => togglePicker('Emoji')} />
</div> </div>
</div> </div>
@@ -1008,6 +1316,17 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
<img src={zoomedImage} alt="Zoomed" style={{ maxWidth: '90%', maxHeight: '90%', boxShadow: '0 8px 16px rgba(0,0,0,0.5)', borderRadius: '4px', cursor: 'default' }} onClick={(e) => e.stopPropagation()} /> <img src={zoomedImage} alt="Zoomed" style={{ maxWidth: '90%', maxHeight: '90%', boxShadow: '0 8px 16px rgba(0,0,0,0.5)', borderRadius: '4px', cursor: 'default' }} onClick={(e) => e.stopPropagation()} />
</div> </div>
)} )}
{profilePopup && (
<UserProfilePopup
userId={profilePopup.userId}
username={profilePopup.username}
avatarUrl={profilePopup.avatarUrl}
status="online"
position={profilePopup.position}
onClose={() => setProfilePopup(null)}
onSendMessage={onOpenDM}
/>
)}
</div> </div>
); );
}; };

View File

@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import Tooltip from './Tooltip';
const ChatHeader = ({ channelName, channelType, channelTopic, onToggleMembers, showMembers, onTogglePinned, serverName }) => {
const [searchFocused, setSearchFocused] = useState(false);
const isDM = channelType === 'dm';
const searchPlaceholder = isDM ? 'Search' : `Search ${serverName || 'Server'}`;
return (
<div className="chat-header">
<div className="chat-header-left">
<span className="chat-header-icon">{isDM ? '@' : '#'}</span>
<span className="chat-header-name">{channelName}</span>
{channelTopic && !isDM && (
<>
<div className="chat-header-divider" />
<span className="chat-header-topic" title={channelTopic}>{channelTopic}</span>
</>
)}
{isDM && <span className="chat-header-status-text"></span>}
</div>
<div className="chat-header-right">
{!isDM && (
<Tooltip text="Threads" position="bottom">
<button className="chat-header-btn">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M5.43309 21C5.35842 21 5.30189 20.9325 5.31494 20.859L5.99991 17H2.14274C2.06819 17 2.01168 16.9327 2.02453 16.8593L2.33253 15.0993C2.34258 15.0419 2.39244 15 2.45074 15H6.34991L7.14991 10.5H3.29274C3.21819 10.5 3.16168 10.4327 3.17453 10.3593L3.48253 8.59926C3.49258 8.54185 3.54244 8.5 3.60074 8.5H7.49991L8.25674 4.49395C8.26688 4.43665 8.31672 4.395 8.37491 4.395H10.1919C10.2666 4.395 10.3231 4.4625 10.3101 4.536L9.59991 8.5H14.0999L14.8568 4.49395C14.8669 4.43665 14.9167 4.395 14.9749 4.395H16.7919C16.8666 4.395 16.9231 4.4625 16.9101 4.536L16.1999 8.5H20.0571C20.1316 8.5 20.1881 8.56734 20.1753 8.64074L19.8673 10.4007C19.8572 10.4581 19.8074 10.5 19.7491 10.5H15.8499L15.0499 15H18.9071C18.9816 15 19.0381 15.0673 19.0253 15.1407L18.7173 16.9007C18.7072 16.9581 18.6574 17 18.5991 17H14.6999L13.9431 21.006C13.9329 21.0634 13.8831 21.105 13.8249 21.105H12.0079C11.9332 21.105 11.8767 21.0375 11.8897 20.964L12.5999 17H8.09991L7.34309 21.006C7.33295 21.0634 7.28311 21.105 7.22491 21.105H5.43309V21ZM8.44991 15H12.9499L13.7499 10.5H9.24991L8.44991 15Z" />
</svg>
</button>
</Tooltip>
)}
<Tooltip text="Pinned Messages" position="bottom">
<button className="chat-header-btn" onClick={onTogglePinned}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.3 5.3a1 1 0 00-1.4-1.4L14.6 7.2l-1.5-.8a2 2 0 00-2.2.2L8.5 9a1 1 0 000 1.5l1.8 1.8-4.6 4.6a1 1 0 001.4 1.4l4.6-4.6 1.8 1.8a1 1 0 001.5 0l2.4-2.4a2 2 0 00.2-2.2l-.8-1.5 3.3-3.3z" />
</svg>
</button>
</Tooltip>
{!isDM && (
<Tooltip text={showMembers ? "Hide Members" : "Show Members"} position="bottom">
<button
className={`chat-header-btn ${showMembers ? 'active' : ''}`}
onClick={onToggleMembers}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 8.006c0 2.206-1.346 4-3 4s-3-1.794-3-4 1.346-4 3-4 3 1.794 3 4zm-6.154 6.63c.896-.758 2.157-1.236 3.654-1.236s2.758.478 3.654 1.236c.898.76 1.346 1.773 1.346 2.87v.5h-10v-.5c0-1.097.448-2.11 1.346-2.87z" />
<path d="M20 10.006c0 1.655-1.01 3-2.25 3s-2.25-1.345-2.25-3S16.51 7.006 17.75 7.006 20 8.351 20 10.006zm-1.146 5.282c.674-.57 1.622-.93 2.646-.93.906 0 1.754.282 2.417.781.663.5 1.083 1.178 1.083 1.867V17.5h-4.6c-.173-.652-.52-1.262-1.032-1.794a5.86 5.86 0 00-.514-.418z" />
</svg>
</button>
</Tooltip>
)}
<Tooltip text="Notification Settings" position="bottom">
<button className="chat-header-btn">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 9V14C18 15.657 19.344 17 21 17V18H3V17C4.656 17 6 15.657 6 14V9C6 5.686 8.686 3 12 3C15.314 3 18 5.686 18 9ZM11.9999 22C10.5239 22 9.24993 20.955 8.99993 19.5H14.9999C14.7499 20.955 13.4759 22 11.9999 22Z" />
</svg>
</button>
</Tooltip>
<div className="chat-header-search-wrapper">
<input
type="text"
placeholder={searchPlaceholder}
className={`chat-header-search ${searchFocused ? 'focused' : ''}`}
onFocus={() => setSearchFocused(true)}
onBlur={() => setSearchFocused(false)}
/>
</div>
</div>
</div>
);
};
export default ChatHeader;

View File

@@ -1,14 +1,24 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useConvex } from 'convex/react'; import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api'; import { api } from '../../../../convex/_generated/api';
import Tooltip from './Tooltip';
import Avatar from './Avatar';
const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => { const STATUS_COLORS = {
const [showUserPicker, setShowUserPicker] = useState(false); online: '#3ba55c',
const [allUsers, setAllUsers] = useState([]); idle: '#faa61a',
const [searchQuery, setSearchQuery] = useState(''); dnd: '#ed4245',
const searchRef = useRef(null); invisible: '#747f8d',
offline: '#747f8d',
};
const convex = useConvex(); const STATUS_LABELS = {
online: 'Online',
idle: 'Idle',
dnd: 'Do Not Disturb',
invisible: 'Offline',
offline: 'Offline',
};
const getUserColor = (username) => { const getUserColor = (username) => {
if (!username) return '#5865F2'; if (!username) return '#5865F2';
@@ -20,10 +30,19 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
return colors[Math.abs(hash) % colors.length]; return colors[Math.abs(hash) % colors.length];
}; };
const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
const [showUserPicker, setShowUserPicker] = useState(false);
const [allUsers, setAllUsers] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [searchFocused, setSearchFocused] = useState(false);
const searchRef = useRef(null);
const searchInputRef = useRef(null);
const convex = useConvex();
const handleOpenUserPicker = async () => { const handleOpenUserPicker = async () => {
setShowUserPicker(true); setShowUserPicker(true);
setSearchQuery(''); setSearchQuery('');
// Fetch all users via Convex query
try { try {
const data = await convex.query(api.auth.getPublicKeys, {}); const data = await convex.query(api.auth.getPublicKeys, {});
const myId = localStorage.getItem('userId'); const myId = localStorage.getItem('userId');
@@ -33,6 +52,19 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
} }
}; };
const handleSearchFocus = async () => {
setSearchFocused(true);
if (allUsers.length === 0) {
try {
const data = await convex.query(api.auth.getPublicKeys, {});
const myId = localStorage.getItem('userId');
setAllUsers(data.filter(u => u.id !== myId));
} catch (err) {
console.error(err);
}
}
};
useEffect(() => { useEffect(() => {
if (showUserPicker && searchRef.current) { if (showUserPicker && searchRef.current) {
searchRef.current.focus(); searchRef.current.focus();
@@ -43,57 +75,69 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
u.username?.toLowerCase().includes(searchQuery.toLowerCase()) u.username?.toLowerCase().includes(searchQuery.toLowerCase())
); );
const handleCloseDM = (e, dm) => {
e.stopPropagation();
// If closing the active DM, switch back to friends
if (activeDMChannel?.channel_id === dm.channel_id) {
onSelectDM('friends');
}
};
return ( return (
<div style={{ padding: '8px', flex: 1, display: 'flex', flexDirection: 'column' }}> <div style={{ padding: '8px', flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Search / New DM Button */} {/* Search Input */}
<button <div className="dm-search-wrapper">
onClick={handleOpenUserPicker} <input
style={{ ref={searchInputRef}
width: '100%', type="text"
textAlign: 'left', className="dm-search-input"
backgroundColor: '#202225', placeholder="Find or start a conversation"
color: '#96989d', value={searchQuery}
padding: '8px 12px', onChange={(e) => setSearchQuery(e.target.value)}
borderRadius: '4px', onFocus={handleSearchFocus}
border: 'none', onBlur={() => {
fontSize: '13px', setTimeout(() => setSearchFocused(false), 200);
marginBottom: '8px', }}
cursor: 'pointer' />
{searchFocused && searchQuery && filteredUsers.length > 0 && (
<div className="dm-search-dropdown">
{filteredUsers.slice(0, 8).map(user => (
<div
key={user.id}
className="dm-search-result"
onMouseDown={(e) => {
e.preventDefault();
setSearchQuery('');
setSearchFocused(false);
onOpenDM(user.id, user.username);
}} }}
> >
Find or start a conversation <Avatar username={user.username} size={24} style={{ marginRight: '8px' }} />
</button> <span>{user.username}</span>
</div>
))}
</div>
)}
</div>
{/* User Picker Modal/Dropdown */} {/* User Picker Modal */}
{showUserPicker && ( {showUserPicker && (
<div style={{ <div style={{
position: 'absolute', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
top: 0, backgroundColor: 'rgba(0,0,0,0.7)', zIndex: 100,
left: 0, display: 'flex', alignItems: 'center', justifyContent: 'center'
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.7)',
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}} }}
onClick={() => setShowUserPicker(false)} onClick={() => setShowUserPicker(false)}
> >
<div <div
style={{ style={{
backgroundColor: '#36393f', backgroundColor: 'var(--bg-primary)', borderRadius: '8px', padding: '16px',
borderRadius: '8px', width: '400px', maxHeight: '500px', display: 'flex', flexDirection: 'column'
padding: '16px',
width: '400px',
maxHeight: '500px',
display: 'flex',
flexDirection: 'column'
}} }}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<h3 style={{ color: '#fff', margin: '0 0 4px 0', fontSize: '16px' }}>Select a User</h3> <h3 style={{ color: 'var(--header-primary)', margin: '0 0 4px 0', fontSize: '16px' }}>Select a User</h3>
<p style={{ color: '#b9bbbe', fontSize: '12px', margin: '0 0 12px 0' }}>Start a new direct message conversation.</p> <p style={{ color: 'var(--header-secondary)', fontSize: '12px', margin: '0 0 12px 0' }}>Start a new direct message conversation.</p>
<input <input
ref={searchRef} ref={searchRef}
type="text" type="text"
@@ -101,52 +145,24 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
value={searchQuery} value={searchQuery}
onChange={e => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
style={{ style={{
width: '100%', width: '100%', backgroundColor: 'var(--bg-tertiary)', border: '1px solid var(--border-subtle)',
backgroundColor: '#202225', borderRadius: '4px', color: 'var(--text-normal)', padding: '8px 12px', fontSize: '14px',
border: '1px solid #040405', outline: 'none', marginBottom: '8px', boxSizing: 'border-box'
borderRadius: '4px',
color: '#dcddde',
padding: '8px 12px',
fontSize: '14px',
outline: 'none',
marginBottom: '8px',
boxSizing: 'border-box'
}} }}
/> />
<div style={{ flex: 1, overflowY: 'auto', maxHeight: '300px' }}> <div style={{ flex: 1, overflowY: 'auto', maxHeight: '300px' }}>
{filteredUsers.map(user => ( {filteredUsers.map(user => (
<div <div
key={user.id} key={user.id}
onClick={() => { className="dm-picker-user"
setShowUserPicker(false); onClick={() => { setShowUserPicker(false); onOpenDM(user.id, user.username); }}
onOpenDM(user.id, user.username);
}}
style={{
display: 'flex',
alignItems: 'center',
padding: '8px',
borderRadius: '4px',
cursor: 'pointer',
color: '#dcddde'
}}
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.06)'}
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
> >
<div style={{ <Avatar username={user.username} size={32} style={{ marginRight: '12px' }} />
width: '32px',
height: '32px',
borderRadius: '50%',
backgroundColor: getUserColor(user.username),
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: '600', marginRight: '12px', flexShrink: 0
}}>
{(user.username ?? '?').substring(0, 1).toUpperCase()}
</div>
<span style={{ fontWeight: '500' }}>{user.username}</span> <span style={{ fontWeight: '500' }}>{user.username}</span>
</div> </div>
))} ))}
{filteredUsers.length === 0 && ( {filteredUsers.length === 0 && (
<div style={{ color: '#72767d', textAlign: 'center', padding: '16px', fontSize: '13px' }}> <div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '16px', fontSize: '13px' }}>
No users found. No users found.
</div> </div>
)} )}
@@ -157,19 +173,8 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
{/* Friends Button */} {/* Friends Button */}
<div <div
style={{ className={`dm-friends-btn ${!activeDMChannel ? 'active' : ''}`}
display: 'flex',
alignItems: 'center',
padding: '10px 8px',
borderRadius: '4px',
backgroundColor: !activeDMChannel ? 'rgba(255,255,255,0.04)' : 'transparent',
color: !activeDMChannel ? '#fff' : '#96989d',
cursor: 'pointer',
marginBottom: '16px'
}}
onClick={() => onSelectDM('friends')} onClick={() => onSelectDM('friends')}
onMouseEnter={e => { if (activeDMChannel) e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.02)'; }}
onMouseLeave={e => { if (activeDMChannel) e.currentTarget.style.backgroundColor = 'transparent'; }}
> >
<div style={{ marginRight: '12px' }}> <div style={{ marginRight: '12px' }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
@@ -183,61 +188,54 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
{/* DM List Header */} {/* DM List Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 8px 8px', color: '#96989d', fontSize: '11px', fontWeight: 'bold', textTransform: 'uppercase' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 8px 8px', color: '#96989d', fontSize: '11px', fontWeight: 'bold', textTransform: 'uppercase' }}>
<span>Direct Messages</span> <span>Direct Messages</span>
<Tooltip text="New DM" position="top">
<span <span
style={{ cursor: 'pointer', fontSize: '16px' }} style={{ cursor: 'pointer', fontSize: '16px' }}
onClick={handleOpenUserPicker} onClick={handleOpenUserPicker}
title="New DM"
>+</span> >+</span>
</Tooltip>
</div> </div>
{/* DM Channel List */} {/* DM Channel List */}
<div style={{ flex: 1, overflowY: 'auto' }}> <div style={{ flex: 1, overflowY: 'auto' }}>
{(dmChannels || []).map(dm => { {(dmChannels || []).map(dm => {
const isActive = activeDMChannel?.channel_id === dm.channel_id; const isActive = activeDMChannel?.channel_id === dm.channel_id;
const status = dm.other_user_status || 'online';
return ( return (
<div <div
key={dm.channel_id} key={dm.channel_id}
className={`dm-item ${isActive ? 'dm-item-active' : ''}`}
onClick={() => onSelectDM({ channel_id: dm.channel_id, other_username: dm.other_username })} onClick={() => onSelectDM({ channel_id: dm.channel_id, other_username: dm.other_username })}
style={{
display: 'flex',
alignItems: 'center',
padding: '8px',
borderRadius: '4px',
cursor: 'pointer',
color: isActive ? '#fff' : '#96989d',
backgroundColor: isActive ? 'rgba(255,255,255,0.06)' : 'transparent'
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.02)'; }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.backgroundColor = 'transparent'; }}
> >
<div style={{ position: 'relative', marginRight: '12px' }}> <div style={{ display: 'flex', alignItems: 'center', flex: 1, minWidth: 0 }}>
<div style={{ <div style={{ position: 'relative', marginRight: '12px', flexShrink: 0 }}>
width: '32px', <Avatar username={dm.other_username} size={32} />
height: '32px',
borderRadius: '50%',
backgroundColor: getUserColor(dm.other_username),
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: '600'
}}>
{(dm.other_username ?? '?').substring(0, 1).toUpperCase()}
</div>
<div style={{ <div style={{
position: 'absolute', bottom: -2, right: -2, position: 'absolute', bottom: -2, right: -2,
width: 10, height: 10, borderRadius: '50%', width: 10, height: 10, borderRadius: '50%',
backgroundColor: '#3ba55c', backgroundColor: STATUS_COLORS[status] || STATUS_COLORS.online,
border: '2px solid #2f3136' border: '2px solid var(--bg-secondary)'
}} /> }} />
</div> </div>
<div style={{ overflow: 'hidden' }}> <div style={{ overflow: 'hidden', flex: 1 }}>
<div style={{ color: isActive ? '#fff' : '#dcddde', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', fontWeight: '500' }}> <div style={{ color: isActive ? 'var(--header-primary)' : 'var(--text-normal)', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', fontWeight: '500' }}>
{dm.other_username} {dm.other_username}
</div> </div>
<div className="dm-item-status">
{STATUS_LABELS[status] || 'Online'}
</div>
</div>
</div>
<div className="dm-close-btn" onClick={(e) => handleCloseDM(e, dm)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path 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>
</div> </div>
</div> </div>
); );
})} })}
{(!dmChannels || dmChannels.length === 0) && ( {(!dmChannels || dmChannels.length === 0) && (
<div style={{ color: '#72767d', fontSize: '13px', textAlign: 'center', padding: '16px 8px' }}> <div style={{ color: 'var(--text-muted)', fontSize: '13px', textAlign: 'center', padding: '16px 8px' }}>
No DMs yet. Click + to start a conversation. No DMs yet. Click + to start a conversation.
</div> </div>
)} )}

View File

@@ -1,13 +1,14 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useQuery } from 'convex/react'; import { useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api'; import { api } from '../../../../convex/_generated/api';
import Avatar from './Avatar';
const FriendsView = ({ onOpenDM }) => { const FriendsView = ({ onOpenDM }) => {
const [activeTab, setActiveTab] = useState('Online'); const [activeTab, setActiveTab] = useState('Online');
const [addFriendSearch, setAddFriendSearch] = useState('');
const myId = localStorage.getItem('userId'); const myId = localStorage.getItem('userId');
// Reactive query for all users' public keys
const allUsers = useQuery(api.auth.getPublicKeys) || []; const allUsers = useQuery(api.auth.getPublicKeys) || [];
const users = allUsers.filter(u => u.id !== myId); const users = allUsers.filter(u => u.id !== myId);
@@ -21,14 +22,26 @@ const FriendsView = ({ onOpenDM }) => {
return colors[Math.abs(hash) % colors.length]; return colors[Math.abs(hash) % colors.length];
}; };
const filteredUsers = users; const STATUS_COLORS = {
online: '#3ba55c',
idle: '#faa61a',
dnd: '#ed4245',
invisible: '#747f8d',
offline: '#747f8d',
};
const filteredUsers = activeTab === 'Online'
? users.filter(u => u.status !== 'offline' && u.status !== 'invisible')
: activeTab === 'Add Friend'
? users.filter(u => u.username?.toLowerCase().includes(addFriendSearch.toLowerCase()))
: users;
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, backgroundColor: 'hsl(240 calc(1*7.143%) 10.98% /1)', height: '100vh' }}> <div style={{ display: 'flex', flexDirection: 'column', flex: 1, backgroundColor: 'var(--bg-primary)', height: '100vh' }}>
{/* Top Bar */} {/* Top Bar */}
<div style={{ <div style={{
height: '48px', height: '48px',
borderBottom: '1px solid #26272d', borderBottom: '1px solid var(--border-subtle)',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
padding: '0 16px', padding: '0 16px',
@@ -36,22 +49,23 @@ const FriendsView = ({ onOpenDM }) => {
fontWeight: 'bold', fontWeight: 'bold',
flexShrink: 0 flexShrink: 0
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', marginRight: '16px', paddingRight: '16px', borderRight: '1px solid #4a4c52' }}> <div style={{ display: 'flex', alignItems: 'center', marginRight: '16px', paddingRight: '16px', borderRight: '1px solid var(--border-subtle)' }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" style={{ marginRight: 8, color: '#72767d' }}> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" style={{ marginRight: 8, color: 'var(--text-muted)' }}>
<path d="M13.5 2C13.5 2.82843 12.8284 3.5 12 3.5C11.1716 3.5 10.5 2.82843 10.5 2C10.5 1.17157 11.1716 0.5 12 0.5C12.8284 0.5 13.5 1.17157 13.5 2Z" fill="currentColor"/> <path d="M13.5 2C13.5 2.82843 12.8284 3.5 12 3.5C11.1716 3.5 10.5 2.82843 10.5 2C10.5 1.17157 11.1716 0.5 12 0.5C12.8284 0.5 13.5 1.17157 13.5 2Z" fill="currentColor"/>
<path d="M7 13C7 11.8954 7.89543 11 9 11H15C16.1046 11 17 11.8954 17 13V15H7V13Z" fill="currentColor"/> <path d="M7 13C7 11.8954 7.89543 11 9 11H15C16.1046 11 17 11.8954 17 13V15H7V13Z" fill="currentColor"/>
</svg> </svg>
Friends Friends
</div> </div>
<div style={{ display: 'flex', gap: '16px' }}> <div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
{['Online', 'All'].map(tab => ( {['Online', 'All'].map(tab => (
<div <div
key={tab} key={tab}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
className="friends-tab"
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
color: activeTab === tab ? '#fff' : '#b9bbbe', color: activeTab === tab ? 'var(--header-primary)' : 'var(--header-secondary)',
backgroundColor: activeTab === tab ? 'rgba(255,255,255,0.06)' : 'transparent', backgroundColor: activeTab === tab ? 'rgba(255,255,255,0.06)' : 'transparent',
padding: '2px 8px', padding: '2px 8px',
borderRadius: '4px' borderRadius: '4px'
@@ -60,18 +74,55 @@ const FriendsView = ({ onOpenDM }) => {
{tab} {tab}
</div> </div>
))} ))}
<div
onClick={() => setActiveTab('Add Friend')}
className="friends-tab"
style={{
cursor: 'pointer',
color: activeTab === 'Add Friend' ? '#fff' : '#3ba55c',
backgroundColor: activeTab === 'Add Friend' ? '#3ba55c' : 'transparent',
padding: '2px 8px',
borderRadius: '4px',
fontWeight: 500,
fontSize: '14px'
}}
>
Add Friend
</div> </div>
</div> </div>
</div>
{activeTab === 'Add Friend' && (
<div style={{ padding: '16px 20px' }}>
<input
type="text"
placeholder="Search for a user to start a conversation..."
value={addFriendSearch}
onChange={(e) => setAddFriendSearch(e.target.value)}
style={{
width: '100%',
backgroundColor: 'var(--bg-tertiary)',
border: '1px solid var(--border-subtle)',
borderRadius: '8px',
color: 'var(--text-normal)',
padding: '10px 14px',
fontSize: '14px',
outline: 'none',
boxSizing: 'border-box'
}}
/>
</div>
)}
{/* List Header */} {/* List Header */}
<div style={{ padding: '16px 20px 8px' }}> <div style={{ padding: '16px 20px 8px' }}>
<div style={{ <div style={{
fontSize: '12px', fontSize: '12px',
fontWeight: 'bold', fontWeight: 'bold',
color: '#b9bbbe', color: 'var(--header-secondary)',
textTransform: 'uppercase' textTransform: 'uppercase'
}}> }}>
{activeTab} {filteredUsers.length} {activeTab === 'Add Friend' ? 'USERS' : activeTab} {filteredUsers.length}
</div> </div>
</div> </div>
@@ -80,56 +131,43 @@ const FriendsView = ({ onOpenDM }) => {
{filteredUsers.map(user => ( {filteredUsers.map(user => (
<div <div
key={user.id} key={user.id}
style={{ className="friend-item"
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 0',
borderTop: '1px solid #3f4147',
cursor: 'pointer',
':hover': { backgroundColor: 'rgba(255,255,255,0.02)' }
}}
> >
<div style={{ display: 'flex', alignItems: 'center' }}> <div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ position: 'relative', marginRight: '12px' }}> <div style={{ position: 'relative', marginRight: '12px' }}>
<div style={{ <Avatar username={user.username} avatarUrl={user.avatarUrl} size={32} />
width: '32px',
height: '32px',
borderRadius: '50%',
backgroundColor: getUserColor(user.username),
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: '600'
}}>
{(user.username ?? '?').substring(0,1).toUpperCase()}
</div>
<div style={{ <div style={{
position: 'absolute', bottom: -2, right: -2, position: 'absolute', bottom: -2, right: -2,
width: 10, height: 10, borderRadius: '50%', width: 10, height: 10, borderRadius: '50%',
backgroundColor: '#3ba55c', backgroundColor: STATUS_COLORS[user.status] || STATUS_COLORS.online,
border: '2px solid #36393f' border: '2px solid var(--bg-primary)'
}} /> }} />
</div> </div>
<div> <div>
<div style={{ color: '#fff', fontWeight: '600' }}> <div style={{ color: 'var(--header-primary)', fontWeight: '600' }}>
{user.username ?? 'Unknown'} {user.username ?? 'Unknown'}
</div> </div>
<div style={{ color: '#b9bbbe', fontSize: '12px' }}> <div style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>
Online {user.status === 'dnd' ? 'Do Not Disturb' : (user.status || 'Online').charAt(0).toUpperCase() + (user.status || 'online').slice(1)}
</div> </div>
</div> </div>
</div> </div>
{/* Actions */}
<div style={{ display: 'flex', gap: '8px' }}> <div style={{ display: 'flex', gap: '8px' }}>
<div <div
style={{ padding: 8, backgroundColor: '#2f3136', borderRadius: '50%', cursor: 'pointer' }} className="friend-action-btn"
onClick={() => onOpenDM && onOpenDM(user.id, user.username)} onClick={() => onOpenDM && onOpenDM(user.id, user.username)}
title="Message"
> >
💬 <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M4.79805 3C3.80445 3 2.99805 3.8055 2.99805 4.8V15.6C2.99805 16.5936 3.80445 17.4 4.79805 17.4H8.39805L11.998 21L15.598 17.4H19.198C20.1925 17.4 20.998 16.5936 20.998 15.6V4.8C20.998 3.8055 20.1925 3 19.198 3H4.79805Z" />
</svg>
</div> </div>
<div style={{ padding: 8, backgroundColor: '#2f3136', borderRadius: '50%', cursor: 'pointer' }}> <div className="friend-action-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 16C13.1046 16 14 15.1046 14 14C14 12.8954 13.1046 12 12 12C10.8954 12 10 12.8954 10 14C10 15.1046 10.8954 16 12 16Z" />
<path d="M12 10C13.1046 10 14 9.10457 14 8C14 6.89543 13.1046 6 12 6C10.8954 6 10 6.89543 10 8C10 9.10457 10.8954 10 12 10Z" />
<path d="M12 22C13.1046 22 14 21.1046 14 20C14 18.8954 13.1046 18 12 18C10.8954 18 10 18.8954 10 20C10 21.1046 10.8954 22 12 22Z" />
</svg>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -9,7 +9,7 @@ const EmojiItem = ({ emoji, onSelect }) => (
onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })} onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })}
title={`:${emoji.name}:`} title={`:${emoji.name}:`}
style={{ cursor: 'pointer', padding: '4px', borderRadius: '4px' }} style={{ cursor: 'pointer', padding: '4px', borderRadius: '4px' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--background-modifier-hover)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
> >
<img src={emoji.src} alt={emoji.name} style={{ width: '32px', height: '32px' }} loading="lazy" /> <img src={emoji.src} alt={emoji.name} style={{ width: '32px', height: '32px' }} loading="lazy" />
@@ -32,7 +32,7 @@ const GifContent = ({ search, results, categories, onSelect, onCategoryClick })
onClick={() => onSelect(gif.media_formats?.gif?.url || gif.src)} onClick={() => onSelect(gif.media_formats?.gif?.url || gif.src)}
/> />
))} ))}
{results.length === 0 && <div style={{ color: '#b9bbbe', gridColumn: 'span 2', textAlign: 'center' }}>No GIFs found</div>} {results.length === 0 && <div style={{ color: 'var(--header-secondary)', gridColumn: 'span 2', textAlign: 'center' }}>No GIFs found</div>}
</div> </div>
); );
} }
@@ -63,7 +63,7 @@ const GifContent = ({ search, results, categories, onSelect, onCategoryClick })
borderRadius: '4px', borderRadius: '4px',
overflow: 'hidden', overflow: 'hidden',
cursor: 'pointer', cursor: 'pointer',
backgroundColor: '#202225' backgroundColor: 'var(--bg-tertiary)'
}} }}
> >
<video <video
@@ -123,7 +123,7 @@ const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory })
padding: '4px', padding: '4px',
borderRadius: '4px' borderRadius: '4px'
}} }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--background-modifier-hover)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
> >
<svg <svg
@@ -132,7 +132,7 @@ const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory })
height="12" height="12"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="#b9bbbe" stroke="var(--header-secondary)"
strokeWidth="3" strokeWidth="3"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@@ -145,7 +145,7 @@ const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory })
<polyline points="6 9 12 15 18 9"></polyline> <polyline points="6 9 12 15 18 9"></polyline>
</svg> </svg>
<h3 style={{ <h3 style={{
color: '#b9bbbe', color: 'var(--header-secondary)',
fontSize: '12px', fontSize: '12px',
textTransform: 'uppercase', textTransform: 'uppercase',
fontWeight: 700, fontWeight: 700,
@@ -235,7 +235,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
right: '0', right: '0',
width: '400px', width: '400px',
height: '450px', height: '450px',
backgroundColor: '#2f3136', backgroundColor: 'var(--embed-background)',
borderRadius: '8px', borderRadius: '8px',
boxShadow: '0 8px 16px rgba(0,0,0,0.24)', boxShadow: '0 8px 16px rgba(0,0,0,0.24)',
display: 'flex', display: 'flex',
@@ -246,7 +246,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Header / Tabs */} {/* Header / Tabs */}
<div style={{ padding: '16px 16px 8px 16px', display: 'flex', gap: '16px', borderBottom: '1px solid #202225' }}> <div style={{ padding: '16px 16px 8px 16px', display: 'flex', gap: '16px', borderBottom: '1px solid var(--bg-tertiary)' }}>
{['GIFs', 'Stickers', 'Emoji'].map(tab => ( {['GIFs', 'Stickers', 'Emoji'].map(tab => (
<button <button
key={tab} key={tab}
@@ -254,12 +254,12 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
style={{ style={{
background: 'none', background: 'none',
border: 'none', border: 'none',
color: activeTab === tab ? '#fff' : '#b9bbbe', color: activeTab === tab ? 'var(--header-primary)' : 'var(--header-secondary)',
fontWeight: 500, fontWeight: 500,
cursor: 'pointer', cursor: 'pointer',
fontSize: '14px', fontSize: '14px',
paddingBottom: '4px', paddingBottom: '4px',
borderBottom: activeTab === tab ? '2px solid #5865f2' : '2px solid transparent' borderBottom: activeTab === tab ? '2px solid var(--brand-experiment)' : '2px solid transparent'
}} }}
> >
{tab} {tab}
@@ -270,7 +270,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
{/* Search Bar */} {/* Search Bar */}
<div style={{ padding: '8px 16px' }}> <div style={{ padding: '8px 16px' }}>
<div style={{ <div style={{
backgroundColor: '#202225', backgroundColor: 'var(--bg-tertiary)',
borderRadius: '4px', borderRadius: '4px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -292,14 +292,14 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
flex: 1, flex: 1,
backgroundColor: 'transparent', backgroundColor: 'transparent',
border: 'none', border: 'none',
color: '#dcddde', color: 'var(--text-normal)',
padding: '8px', padding: '8px',
fontSize: '14px', fontSize: '14px',
outline: 'none' outline: 'none'
}} }}
/> />
<div style={{ padding: '4px' }}> <div style={{ padding: '4px' }}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#b9bbbe" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--header-secondary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"></circle> <circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line> <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg> </svg>
@@ -310,7 +310,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
{/* Content Area */} {/* Content Area */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 16px 16px 16px' }}> <div style={{ flex: 1, overflowY: 'auto', padding: '0 16px 16px 16px' }}>
{loading ? ( {loading ? (
<div style={{ color: '#b9bbbe', textAlign: 'center', padding: '20px' }}>Loading...</div> <div style={{ color: 'var(--header-secondary)', textAlign: 'center', padding: '20px' }}>Loading...</div>
) : activeTab === 'GIFs' ? ( ) : activeTab === 'GIFs' ? (
<GifContent search={search} results={results} categories={categories} onSelect={onSelect} onCategoryClick={setSearch} /> <GifContent search={search} results={results} categories={categories} onSelect={onSelect} onCategoryClick={setSearch} />
) : ( ) : (

View File

@@ -0,0 +1,130 @@
import React from 'react';
import { useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
function getUserColor(name) {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
}
const STATUS_COLORS = {
online: '#3ba55c',
idle: '#faa61a',
dnd: '#ed4245',
invisible: '#747f8d',
offline: '#747f8d',
};
const MembersList = ({ channelId, visible, onMemberClick }) => {
const members = useQuery(
api.members.getChannelMembers,
channelId ? { channelId } : "skip"
) || [];
if (!visible) return null;
const onlineMembers = members.filter(m => m.status !== 'offline' && m.status !== 'invisible');
const offlineMembers = members.filter(m => m.status === 'offline' || m.status === 'invisible');
// Group online members by highest hoisted role
const roleGroups = {};
const ungrouped = [];
onlineMembers.forEach(member => {
const hoistedRole = member.roles.find(r => r.isHoist && r.name !== '@everyone');
if (hoistedRole) {
const key = `${hoistedRole.position}_${hoistedRole.name}`;
if (!roleGroups[key]) {
roleGroups[key] = { role: hoistedRole, members: [] };
}
roleGroups[key].members.push(member);
} else {
ungrouped.push(member);
}
});
// Sort groups by position descending
const sortedGroups = Object.values(roleGroups).sort((a, b) => b.role.position - a.role.position);
const renderMember = (member) => {
const topRole = member.roles.length > 0 ? member.roles[0] : null;
const nameColor = topRole && topRole.name !== '@everyone' ? topRole.color : '#fff';
return (
<div
key={member.id}
className="member-item"
onClick={() => onMemberClick && onMemberClick(member)}
style={member.status === 'offline' || member.status === 'invisible' ? { opacity: 0.3 } : {}}
>
<div className="member-avatar-wrapper">
{member.avatarUrl ? (
<img
className="member-avatar"
src={member.avatarUrl}
alt={member.username}
style={{ objectFit: 'cover' }}
/>
) : (
<div
className="member-avatar"
style={{ backgroundColor: getUserColor(member.username) }}
>
{member.username.substring(0, 1).toUpperCase()}
</div>
)}
<div
className="member-status-dot"
style={{ backgroundColor: STATUS_COLORS[member.status] || STATUS_COLORS.online }}
/>
</div>
<div className="member-info">
<span className="member-name" style={{ color: nameColor }}>
{member.username}
</span>
{member.customStatus && (
<div style={{ fontSize: '12px', color: 'var(--text-muted)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{member.customStatus}
</div>
)}
</div>
</div>
);
};
return (
<div className="members-list">
{sortedGroups.map(group => (
<React.Fragment key={group.role.name}>
<div className="members-role-header">
{group.role.name} {group.members.length}
</div>
{group.members.map(renderMember)}
</React.Fragment>
))}
{ungrouped.length > 0 && (
<>
<div className="members-role-header">
ONLINE {ungrouped.length}
</div>
{ungrouped.map(renderMember)}
</>
)}
{offlineMembers.length > 0 && (
<>
<div className="members-role-header">
OFFLINE {offlineMembers.length}
</div>
{offlineMembers.map(renderMember)}
</>
)}
</div>
);
};
export default MembersList;

View File

@@ -0,0 +1,43 @@
import React, { useEffect, useRef } from 'react';
import Avatar from './Avatar';
const MentionMenu = ({ members, selectedIndex, onSelect, onHover }) => {
const scrollerRef = useRef(null);
useEffect(() => {
if (!scrollerRef.current) return;
const selected = scrollerRef.current.querySelector('.mention-menu-row.selected');
if (selected) selected.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
if (!members || members.length === 0) return null;
return (
<div className="mention-menu">
<div className="mention-menu-header">Members</div>
<div className="mention-menu-scroller" ref={scrollerRef}>
{members.map((member, i) => {
const topRole = member.roles && member.roles.length > 0 ? member.roles[0] : null;
const nameColor = topRole?.color || undefined;
return (
<div
key={member.id}
className={`mention-menu-row${i === selectedIndex ? ' selected' : ''}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelect(member)}
onMouseEnter={() => onHover(i)}
>
<Avatar username={member.username} avatarUrl={member.avatarUrl} size={24} />
<span className="mention-menu-row-primary" style={nameColor ? { color: nameColor } : undefined}>
{member.username}
</span>
<span className="mention-menu-row-secondary">{member.username}</span>
</div>
);
})}
</div>
</div>
);
};
export default MentionMenu;

View File

@@ -0,0 +1,89 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const PinnedMessagesPanel = ({ channelId, visible, onClose, channelKey, onJumpToMessage }) => {
const [decryptedPins, setDecryptedPins] = useState([]);
const pinnedMessages = useQuery(
api.messages.listPinned,
channelId ? { channelId } : "skip"
) || [];
const unpinMutation = useMutation(api.messages.pin);
useEffect(() => {
if (!pinnedMessages.length || !channelKey) {
setDecryptedPins([]);
return;
}
let cancelled = false;
const decrypt = async () => {
const results = await Promise.all(
pinnedMessages.map(async (msg) => {
try {
const TAG_LENGTH = 32;
const tag = msg.ciphertext.slice(-TAG_LENGTH);
const content = msg.ciphertext.slice(0, -TAG_LENGTH);
const decrypted = await window.cryptoAPI.decryptData(content, channelKey, msg.nonce, tag);
return { ...msg, content: decrypted };
} catch {
return { ...msg, content: '[Encrypted Message]' };
}
})
);
if (!cancelled) setDecryptedPins(results);
};
decrypt();
return () => { cancelled = true; };
}, [pinnedMessages, channelKey]);
if (!visible) return null;
return (
<div className="pinned-panel">
<div className="pinned-panel-header">
<h3>Pinned Messages</h3>
<button className="pinned-panel-close" onClick={onClose}>&times;</button>
</div>
<div className="pinned-panel-content">
{decryptedPins.length === 0 ? (
<div className="pinned-panel-empty">
No pinned messages in this channel yet.
</div>
) : (
decryptedPins.map(msg => (
<div key={msg.id} className="pinned-message-item">
<div className="pinned-message-header">
<span className="pinned-message-author">{msg.username}</span>
<span className="pinned-message-date">
{new Date(msg.created_at).toLocaleDateString()}
</span>
</div>
<div className="pinned-message-content">
{msg.content?.startsWith('{') ? '[Attachment]' : msg.content}
</div>
<div className="pinned-message-actions">
<button
className="pinned-action-btn"
onClick={() => onJumpToMessage && onJumpToMessage(msg.id)}
>
Jump
</button>
<button
className="pinned-action-btn pinned-action-danger"
onClick={() => unpinMutation({ id: msg.id, pinned: false })}
>
Unpin
</button>
</div>
</div>
))
)}
</div>
</div>
);
};
export default PinnedMessagesPanel;

View File

@@ -67,7 +67,7 @@ const ServerSettingsModal = ({ onClose }) => {
display: 'flex', flexDirection: 'column', alignItems: 'flex-end', padding: '60px 6px 60px 20px' display: 'flex', flexDirection: 'column', alignItems: 'flex-end', padding: '60px 6px 60px 20px'
}}> }}>
<div style={{ width: '100%', padding: '0 10px' }}> <div style={{ width: '100%', padding: '0 10px' }}>
<div style={{ fontSize: '12px', fontWeight: '700', color: '#96989d', marginBottom: '6px', textTransform: 'uppercase' }}> <div style={{ fontSize: '12px', fontWeight: '700', color: 'var(--text-muted)', marginBottom: '6px', textTransform: 'uppercase' }}>
Server Settings Server Settings
</div> </div>
{['Overview', 'Roles', 'Members'].map(tab => ( {['Overview', 'Roles', 'Members'].map(tab => (
@@ -76,8 +76,8 @@ const ServerSettingsModal = ({ onClose }) => {
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
style={{ style={{
padding: '6px 10px', borderRadius: '4px', padding: '6px 10px', borderRadius: '4px',
backgroundColor: activeTab === tab ? '#393c43' : 'transparent', backgroundColor: activeTab === tab ? 'var(--background-modifier-selected)' : 'transparent',
color: activeTab === tab ? '#fff' : '#b9bbbe', color: activeTab === tab ? 'var(--header-primary)' : 'var(--header-secondary)',
cursor: 'pointer', marginBottom: '2px', fontSize: '15px' cursor: 'pointer', marginBottom: '2px', fontSize: '15px'
}} }}
> >
@@ -90,16 +90,16 @@ const ServerSettingsModal = ({ onClose }) => {
const canManageRoles = myPermissions.manage_roles; const canManageRoles = myPermissions.manage_roles;
const disabledOpacity = canManageRoles ? 1 : 0.5; const disabledOpacity = canManageRoles ? 1 : 0.5;
const labelStyle = { display: 'block', color: '#b9bbbe', fontSize: '12px', fontWeight: '700', marginBottom: 8 }; const labelStyle = { display: 'block', color: 'var(--header-secondary)', fontSize: '12px', fontWeight: '700', marginBottom: 8 };
const editableRoles = roles.filter(r => r.name !== 'Owner'); const editableRoles = roles.filter(r => r.name !== 'Owner');
const renderRolesTab = () => ( const renderRolesTab = () => (
<div style={{ display: 'flex', height: '100%' }}> <div style={{ display: 'flex', height: '100%' }}>
<div style={{ width: '200px', borderRight: '1px solid #3f4147', marginRight: '20px' }}> <div style={{ width: '200px', borderRight: '1px solid var(--border-subtle)', marginRight: '20px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '10px' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '10px' }}>
<h3 style={{ color: '#b9bbbe', fontSize: '12px' }}>ROLES</h3> <h3 style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>ROLES</h3>
{canManageRoles && ( {canManageRoles && (
<button onClick={handleCreateRole} style={{ background: 'transparent', border: 'none', color: '#b9bbbe', cursor: 'pointer' }}>+</button> <button onClick={handleCreateRole} style={{ background: 'transparent', border: 'none', color: 'var(--header-secondary)', cursor: 'pointer' }}>+</button>
)} )}
</div> </div>
{editableRoles.map(r => ( {editableRoles.map(r => (
@@ -108,7 +108,7 @@ const ServerSettingsModal = ({ onClose }) => {
onClick={() => setSelectedRole(r)} onClick={() => setSelectedRole(r)}
style={{ style={{
padding: '6px', padding: '6px',
backgroundColor: selectedRole?._id === r._id ? '#40444b' : 'transparent', backgroundColor: selectedRole?._id === r._id ? 'var(--background-modifier-selected)' : 'transparent',
borderRadius: '4px', cursor: 'pointer', color: r.color || '#b9bbbe', borderRadius: '4px', cursor: 'pointer', color: r.color || '#b9bbbe',
display: 'flex', alignItems: 'center' display: 'flex', alignItems: 'center'
}} }}
@@ -121,14 +121,14 @@ const ServerSettingsModal = ({ onClose }) => {
{selectedRole ? ( {selectedRole ? (
<div className="no-scrollbar" style={{ flex: 1, overflowY: 'auto' }}> <div className="no-scrollbar" style={{ flex: 1, overflowY: 'auto' }}>
<h2 style={{ color: 'white', marginTop: 0 }}>Edit Role - {selectedRole.name}</h2> <h2 style={{ color: 'var(--header-primary)', marginTop: 0 }}>Edit Role - {selectedRole.name}</h2>
<label style={labelStyle}>ROLE NAME</label> <label style={labelStyle}>ROLE NAME</label>
<input <input
value={selectedRole.name} value={selectedRole.name}
onChange={(e) => handleUpdateRole(selectedRole._id, { name: e.target.value })} onChange={(e) => handleUpdateRole(selectedRole._id, { name: e.target.value })}
disabled={!canManageRoles} disabled={!canManageRoles}
style={{ width: '100%', padding: 10, background: '#202225', border: 'none', borderRadius: 4, color: 'white', marginBottom: 20, opacity: disabledOpacity }} style={{ width: '100%', padding: 10, background: 'var(--bg-tertiary)', border: 'none', borderRadius: 4, color: 'var(--header-primary)', marginBottom: 20, opacity: disabledOpacity }}
/> />
<label style={labelStyle}>ROLE COLOR</label> <label style={labelStyle}>ROLE COLOR</label>
@@ -142,8 +142,8 @@ const ServerSettingsModal = ({ onClose }) => {
<label style={labelStyle}>PERMISSIONS</label> <label style={labelStyle}>PERMISSIONS</label>
{['manage_channels', 'manage_roles', 'create_invite', 'embed_links', 'attach_files'].map(perm => ( {['manage_channels', 'manage_roles', 'create_invite', 'embed_links', 'attach_files'].map(perm => (
<div key={perm} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10, paddingBottom: 10, borderBottom: '1px solid #3f4147' }}> <div key={perm} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10, paddingBottom: 10, borderBottom: '1px solid var(--border-subtle)' }}>
<span style={{ color: 'white', textTransform: 'capitalize' }}>{perm.replace('_', ' ')}</span> <span style={{ color: 'var(--header-primary)', textTransform: 'capitalize' }}>{perm.replace('_', ' ')}</span>
<input <input
type="checkbox" type="checkbox"
checked={selectedRole.permissions?.[perm] || false} checked={selectedRole.permissions?.[perm] || false}
@@ -165,24 +165,24 @@ const ServerSettingsModal = ({ onClose }) => {
)} )}
</div> </div>
) : ( ) : (
<div style={{ color: '#b9bbbe' }}>Select a role to edit</div> <div style={{ color: 'var(--header-secondary)' }}>Select a role to edit</div>
)} )}
</div> </div>
); );
const renderMembersTab = () => ( const renderMembersTab = () => (
<div> <div>
<h2 style={{ color: 'white' }}>Members</h2> <h2 style={{ color: 'var(--header-primary)' }}>Members</h2>
{members.map(m => ( {members.map(m => (
<div key={m.id} style={{ display: 'flex', alignItems: 'center', padding: '10px', borderBottom: '1px solid #3f4147' }}> <div key={m.id} style={{ display: 'flex', alignItems: 'center', padding: '10px', borderBottom: '1px solid var(--border-subtle)' }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#5865F2', marginRight: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white' }}> <div style={{ width: 32, height: 32, borderRadius: '50%', background: '#5865F2', marginRight: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--header-primary)' }}>
{m.username[0].toUpperCase()} {m.username[0].toUpperCase()}
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ color: 'white', fontWeight: 'bold' }}>{m.username}</div> <div style={{ color: 'var(--header-primary)', fontWeight: 'bold' }}>{m.username}</div>
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
{m.roles?.map(r => ( {m.roles?.map(r => (
<span key={r._id} style={{ fontSize: 10, background: r.color, color: 'white', padding: '2px 4px', borderRadius: 4 }}> <span key={r._id} style={{ fontSize: 10, background: r.color, color: 'var(--header-primary)', padding: '2px 4px', borderRadius: 4 }}>
{r.name} {r.name}
</span> </span>
))} ))}
@@ -217,17 +217,17 @@ const ServerSettingsModal = ({ onClose }) => {
switch (activeTab) { switch (activeTab) {
case 'Roles': return renderRolesTab(); case 'Roles': return renderRolesTab();
case 'Members': return renderMembersTab(); case 'Members': return renderMembersTab();
default: return <div style={{ color: '#b9bbbe' }}>Server Name: Secure Chat<br/>Region: US-East</div>; default: return <div style={{ color: 'var(--header-secondary)' }}>Server Name: Secure Chat<br/>Region: US-East</div>;
} }
}; };
return ( return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'hsl(240 calc(1*7.143%) 10.98% /1)', zIndex: 1000, display: 'flex', color: '#dcddde' }}> <div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'var(--bg-primary)', zIndex: 1000, display: 'flex', color: 'var(--text-normal)' }}>
{renderSidebar()} {renderSidebar()}
<div style={{ flex: 1, padding: '60px 40px' }}> <div style={{ flex: 1, padding: '60px 40px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 20 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 20 }}>
<h2 style={{ color: 'white', margin: 0 }}>{activeTab}</h2> <h2 style={{ color: 'var(--header-primary)', margin: 0 }}>{activeTab}</h2>
<button onClick={onClose} style={{ background: 'transparent', border: '1px solid #b9bbbe', borderRadius: '50%', width: 36, height: 36, color: '#b9bbbe', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}></button> <button onClick={onClose} style={{ background: 'transparent', border: '1px solid #b9bbbe', borderRadius: '50%', width: 36, height: 36, color: 'var(--header-secondary)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}></button>
</div> </div>
{renderTabContent()} {renderTabContent()}
</div> </div>

View File

@@ -1,11 +1,14 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useConvex } from 'convex/react'; import { useConvex, useMutation } from 'convex/react';
import { api } from '../../../../convex/_generated/api'; import { api } from '../../../../convex/_generated/api';
import Tooltip from './Tooltip';
import { useVoice } from '../contexts/VoiceContext'; import { useVoice } from '../contexts/VoiceContext';
import ChannelSettingsModal from './ChannelSettingsModal'; import ChannelSettingsModal from './ChannelSettingsModal';
import ServerSettingsModal from './ServerSettingsModal'; import ServerSettingsModal from './ServerSettingsModal';
import ScreenShareModal from './ScreenShareModal'; import ScreenShareModal from './ScreenShareModal';
import DMList from './DMList'; import DMList from './DMList';
import Avatar from './Avatar';
import ThemeSelector from './ThemeSelector';
import { Track } from 'livekit-client'; import { Track } from 'livekit-client';
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';
@@ -74,47 +77,80 @@ const ColoredIcon = ({ src, color, size = '20px' }) => (
</div> </div>
); );
const UserControlPanel = ({ username }) => { const VoiceTimer = () => {
const [elapsed, setElapsed] = React.useState(0);
React.useEffect(() => {
const start = Date.now();
const interval = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1000)), 1000);
return () => clearInterval(interval);
}, []);
const hours = Math.floor(elapsed / 3600);
const mins = Math.floor((elapsed % 3600) / 60);
const secs = elapsed % 60;
const time = hours > 0
? `${hours}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
: `${mins}:${String(secs).padStart(2, '0')}`;
return <span className="voice-timer">{time}</span>;
};
const STATUS_OPTIONS = [
{ value: 'online', label: 'Online', color: '#3ba55c' },
{ value: 'idle', label: 'Idle', color: '#faa61a' },
{ value: 'dnd', label: 'Do Not Disturb', color: '#ed4245' },
{ value: 'invisible', label: 'Invisible', color: '#747f8d' },
];
const UserControlPanel = ({ username, userId }) => {
const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState } = useVoice(); const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState } = useVoice();
const [showStatusMenu, setShowStatusMenu] = useState(false);
const [showThemeSelector, setShowThemeSelector] = useState(false);
const [currentStatus, setCurrentStatus] = useState('online');
const updateStatusMutation = useMutation(api.auth.updateStatus);
const effectiveMute = isMuted || isDeafened; const effectiveMute = isMuted || isDeafened;
const statusColor = STATUS_OPTIONS.find(s => s.value === currentStatus)?.color || '#3ba55c';
const handleStatusChange = async (status) => {
setCurrentStatus(status);
setShowStatusMenu(false);
if (userId) {
try {
await updateStatusMutation({ userId, status });
} catch (e) {
console.error('Failed to update status:', e);
}
}
};
return ( return (
<div style={{ <div style={{
height: '64px', height: '64px',
margin: '0 8px 8px 8px', margin: '0 8px 8px 8px',
borderRadius: connectionState === 'connected' ? '0px 0px 8px 8px' : '8px', borderRadius: connectionState === 'connected' ? '0px 0px 8px 8px' : '8px',
backgroundColor: '#292b2f', backgroundColor: 'var(--panel-bg)',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
padding: '0 8px', padding: '0 8px',
flexShrink: 0 flexShrink: 0,
position: 'relative'
}}> }}>
{/* User Info */} {showStatusMenu && (
<div style={{ <div className="status-menu">
display: 'flex', {STATUS_OPTIONS.map(opt => (
alignItems: 'center', <div
marginRight: 'auto', key={opt.value}
padding: '4px', className="status-menu-item"
borderRadius: '4px', onClick={() => handleStatusChange(opt.value)}
cursor: 'pointer', >
':hover': { backgroundColor: 'rgba(255,255,255,0.05)' } <div className="status-menu-dot" style={{ backgroundColor: opt.color }} />
}}> <span>{opt.label}</span>
<div style={{ position: 'relative', marginRight: '8px' }}>
<div style={{
width: '32px',
height: '32px',
borderRadius: '50%',
backgroundColor: getUserColor(username || 'U'),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontWeight: '600',
fontSize: '14px'
}}>
{(username || '?').substring(0, 1).toUpperCase()}
</div> </div>
))}
</div>
)}
<div className="user-control-info" onClick={() => setShowStatusMenu(!showStatusMenu)}>
<div style={{ position: 'relative', marginRight: '8px' }}>
<Avatar username={username} size={32} />
<div style={{ <div style={{
position: 'absolute', position: 'absolute',
bottom: '-2px', bottom: '-2px',
@@ -122,41 +158,48 @@ const UserControlPanel = ({ username }) => {
width: '10px', width: '10px',
height: '10px', height: '10px',
borderRadius: '50%', borderRadius: '50%',
backgroundColor: '#3ba55c', backgroundColor: statusColor,
border: '2px solid #292b2f' border: '2px solid var(--panel-bg)',
cursor: 'pointer'
}} /> }} />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ color: 'white', fontWeight: '600', fontSize: '14px', lineHeight: '18px' }}> <div style={{ color: 'var(--header-primary)', fontWeight: '600', fontSize: '14px', lineHeight: '18px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{username || 'Unknown'} {username || 'Unknown'}
</div> </div>
<div style={{ color: '#b9bbbe', fontSize: '12px', lineHeight: '13px' }}> <div style={{ color: 'var(--header-secondary)', fontSize: '12px', lineHeight: '13px' }}>
#{Math.floor(Math.random() * 9000) + 1000} {STATUS_OPTIONS.find(s => s.value === currentStatus)?.label || 'Online'}
</div> </div>
</div> </div>
</div> </div>
{/* Controls */}
<div style={{ display: 'flex' }}> <div style={{ display: 'flex' }}>
<button onClick={toggleMute} title={effectiveMute ? "Unmute" : "Mute"} style={controlButtonStyle}> <Tooltip text={effectiveMute ? "Unmute" : "Mute"} position="top">
<button onClick={toggleMute} style={controlButtonStyle}>
<ColoredIcon <ColoredIcon
src={effectiveMute ? mutedIcon : muteIcon} src={effectiveMute ? mutedIcon : muteIcon}
color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT} color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
/> />
</button> </button>
<button onClick={toggleDeafen} title={isDeafened ? "Undeafen" : "Deafen"} style={controlButtonStyle}> </Tooltip>
<Tooltip text={isDeafened ? "Undeafen" : "Deafen"} position="top">
<button onClick={toggleDeafen} style={controlButtonStyle}>
<ColoredIcon <ColoredIcon
src={isDeafened ? defeanedIcon : defeanIcon} src={isDeafened ? defeanedIcon : defeanIcon}
color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT} color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
/> />
</button> </button>
<button title="User Settings" style={controlButtonStyle}> </Tooltip>
<Tooltip text="User Settings" position="top">
<button style={controlButtonStyle} onClick={() => setShowThemeSelector(true)}>
<ColoredIcon <ColoredIcon
src={settingsIcon} src={settingsIcon}
color={ICON_COLOR_DEFAULT} color={ICON_COLOR_DEFAULT}
/> />
</button> </button>
</Tooltip>
</div> </div>
{showThemeSelector && <ThemeSelector onClose={() => setShowThemeSelector(false)} />}
</div> </div>
); );
}; };
@@ -166,7 +209,7 @@ const UserControlPanel = ({ username }) => {
const headerButtonStyle = { const headerButtonStyle = {
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
color: '#b9bbbe', color: 'var(--header-secondary)',
cursor: 'pointer', cursor: 'pointer',
fontSize: '18px', fontSize: '18px',
padding: '0 4px' padding: '0 4px'
@@ -249,13 +292,14 @@ function getScreenCaptureConstraints(selection) {
}; };
} }
const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels }) => { const Sidebar = ({ channels, 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('');
const [newChannelType, setNewChannelType] = useState('text'); const [newChannelType, setNewChannelType] = useState('text');
const [editingChannel, setEditingChannel] = useState(null); const [editingChannel, setEditingChannel] = useState(null);
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false); const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
const [collapsedCategories, setCollapsedCategories] = useState({});
const convex = useConvex(); const convex = useConvex();
@@ -419,23 +463,22 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
<div style={{ marginLeft: 32, marginBottom: 8 }}> <div style={{ marginLeft: 32, marginBottom: 8 }}>
{users.map(user => ( {users.map(user => (
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}> <div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
<div style={{ <Avatar
width: 24, height: 24, borderRadius: '50%', username={user.username}
backgroundColor: '#5865F2', size={24}
display: 'flex', alignItems: 'center', justifyContent: 'center', style={{
marginRight: 8, fontSize: 10, color: 'white', marginRight: 8,
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none' boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
}}> }}
{user.username.substring(0, 1).toUpperCase()} />
</div> <span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{user.username}</span>
<span style={{ color: '#b9bbbe', fontSize: 14 }}>{user.username}</span>
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center' }}> <div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center' }}>
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>} {user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
{(user.isMuted || user.isDeafened) && ( {(user.isMuted || user.isDeafened) && (
<ColoredIcon src={mutedIcon} color="#b9bbbe" size="14px" /> <ColoredIcon src={mutedIcon} color="var(--header-secondary)" size="14px" />
)} )}
{user.isDeafened && ( {user.isDeafened && (
<ColoredIcon src={defeanedIcon} color="#b9bbbe" size="14px" /> <ColoredIcon src={defeanedIcon} color="var(--header-secondary)" size="14px" />
)} )}
</div> </div>
</div> </div>
@@ -444,34 +487,38 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
); );
}; };
const toggleCategory = (cat) => {
setCollapsedCategories(prev => ({ ...prev, [cat]: !prev[cat] }));
};
const groupedChannels = React.useMemo(() => {
const groups = {};
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);
});
return groups;
}, [channels]);
const renderServerView = () => ( const renderServerView = () => (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}> <div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<div className="channel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div className="server-header" onClick={() => setIsServerSettingsOpen(true)}>
<span <span>Secure Chat</span>
style={{ cursor: 'pointer', maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis' }} <span className="server-header-chevron"></span>
onClick={() => setIsServerSettingsOpen(true)}
title="Server Settings"
>
Secure Chat
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={handleStartCreate} title="Create New Channel" style={{ ...headerButtonStyle, marginRight: '4px' }}>
+
</button>
<button onClick={handleCreateInvite} title="Create Invite Link" style={headerButtonStyle}>
🔗
</button>
</div>
</div> </div>
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
{isCreating && ( {isCreating && (
<div style={{ padding: '0 8px', marginBottom: '4px' }}> <div style={{ padding: '0 8px', marginBottom: '4px' }}>
<form onSubmit={handleSubmitCreate}> <form onSubmit={handleSubmitCreate}>
<div style={{ display: 'flex', gap: 4, marginBottom: 4 }}> <div style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<label style={{ color: newChannelType==='text'?'white':'#b9bbbe', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('text')}> <label style={{ color: newChannelType==='text'?'white':'var(--interactive-normal)', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('text')}>
Text Text
</label> </label>
<label style={{ color: newChannelType==='voice'?'white':'#b9bbbe', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('voice')}> <label style={{ color: newChannelType==='voice'?'white':'var(--interactive-normal)', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('voice')}>
Voice Voice
</label> </label>
</div> </div>
@@ -483,23 +530,32 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
onChange={(e) => setNewChannelName(e.target.value)} onChange={(e) => setNewChannelName(e.target.value)}
style={{ style={{
width: '100%', width: '100%',
background: '#202225', background: 'var(--bg-tertiary)',
border: '1px solid #7289da', border: '1px solid var(--brand-experiment)',
borderRadius: '4px', borderRadius: '4px',
color: '#dcddde', color: 'var(--text-normal)',
padding: '4px 8px', padding: '4px 8px',
fontSize: '14px', fontSize: '14px',
outline: 'none' outline: 'none'
}} }}
/> />
</form> </form>
<div style={{ fontSize: 10, color: '#b9bbbe', marginTop: 2, textAlign: 'right' }}> <div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 2, textAlign: 'right' }}>
Press Enter to Create {newChannelType === 'voice' && '(Voice)'} Press Enter to Create {newChannelType === 'voice' && '(Voice)'}
</div> </div>
</div> </div>
)} )}
{channels.map(channel => ( {Object.entries(groupedChannels).map(([category, catChannels]) => (
<div key={category}>
<div className="channel-category-header" onClick={() => toggleCategory(category)}>
<span className={`category-chevron ${collapsedCategories[category] ? 'collapsed' : ''}`}></span>
<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 => (
<React.Fragment key={channel._id}> <React.Fragment key={channel._id}>
<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' : ''}`}
@@ -518,11 +574,11 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
<ColoredIcon <ColoredIcon
src={voiceIcon} src={voiceIcon}
size="16px" size="16px"
color={voiceStates[channel._id]?.length > 0 ? VOICE_ACTIVE_COLOR : "#8e9297"} color={voiceStates[channel._id]?.length > 0 ? VOICE_ACTIVE_COLOR : "var(--interactive-normal)"}
/> />
</div> </div>
) : ( ) : (
<span style={{ color: '#8e9297', marginRight: '6px', flexShrink: 0 }}>#</span> <span style={{ color: 'var(--interactive-normal)', marginRight: '6px', flexShrink: 0 }}>#</span>
)} )}
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{channel.name}</span> <span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{channel.name}</span>
</div> </div>
@@ -536,12 +592,12 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
style={{ style={{
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
color: '#b9bbbe', color: 'var(--interactive-normal)',
cursor: 'pointer', cursor: 'pointer',
fontSize: '12px', fontSize: '12px',
padding: '2px 4px', padding: '2px 4px',
display: 'flex', alignItems: 'center', display: 'flex', alignItems: 'center',
opacity: '0.7', opacity: '0',
transition: 'opacity 0.2s' transition: 'opacity 0.2s'
}} }}
> >
@@ -552,19 +608,24 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</React.Fragment> </React.Fragment>
))} ))}
</div> </div>
))}
</div>
</div>
); );
return ( return (
<div className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}> <div className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}> <div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div className="server-list"> <div className="server-list">
<div className="server-item-wrapper">
<div className={`server-pill ${view === 'me' ? 'active' : ''}`} />
<Tooltip text="Direct Messages" position="right">
<div <div
className={`server-icon ${view === 'me' ? 'active' : ''}`} className={`server-icon ${view === 'me' ? 'active' : ''}`}
onClick={() => onViewChange('me')} onClick={() => onViewChange('me')}
style={{ style={{
backgroundColor: view === 'me' ? '#5865F2' : '#36393f', backgroundColor: view === 'me' ? 'var(--brand-experiment)' : 'var(--bg-primary)',
color: view === 'me' ? '#fff' : '#dcddde', color: view === 'me' ? '#fff' : 'var(--text-normal)',
marginBottom: '8px',
cursor: 'pointer' cursor: 'pointer'
}} }}
> >
@@ -572,12 +633,21 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
<path fill="currentColor" d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.0172 1.11817 13.0907 1.11817 11.1372 1.4184C10.9331 0.934541 10.7018 0.461742 10.4496 0C8.59007 0.318797 6.78726 0.879656 5.0768 1.67671C1.65217 6.84883 0.706797 11.8997 1.166 16.858C3.39578 18.5135 5.56066 19.5165 7.6473 20.1417C8.16912 19.4246 8.63212 18.6713 9.02347 17.8863C8.25707 17.5929 7.52187 17.2415 6.82914 16.8374C7.0147 16.6975 7.19515 16.5518 7.36941 16.402C11.66 18.396 16.4523 18.396 20.7186 16.402C20.8953 16.5518 21.0758 16.6975 21.2613 16.8374C20.5663 17.2435 19.8288 17.5947 19.0624 17.8863C19.4537 18.6713 19.9167 19.4246 20.4385 20.1417C22.5276 19.5165 24.6925 18.5135 26.9223 16.858C27.4684 11.2365 25.9961 6.22055 23.0212 1.67671ZM9.68041 13.6383C8.39754 13.6383 7.34085 12.4456 7.34085 10.994C7.34085 9.5424 8.37555 8.34973 9.68041 8.34973C10.9893 8.34973 12.0395 9.5424 12.0175 10.994C12.0175 12.4456 10.9893 13.6383 9.68041 13.6383ZM18.3161 13.6383C17.0332 13.6383 15.9765 12.4456 15.9765 10.994C15.9765 9.5424 17.0112 8.34973 18.3161 8.34973C19.625 8.34973 20.6752 9.5424 20.6532 10.994C20.6532 12.4456 19.625 13.6383 18.3161 13.6383Z"/> <path fill="currentColor" d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.0172 1.11817 13.0907 1.11817 11.1372 1.4184C10.9331 0.934541 10.7018 0.461742 10.4496 0C8.59007 0.318797 6.78726 0.879656 5.0768 1.67671C1.65217 6.84883 0.706797 11.8997 1.166 16.858C3.39578 18.5135 5.56066 19.5165 7.6473 20.1417C8.16912 19.4246 8.63212 18.6713 9.02347 17.8863C8.25707 17.5929 7.52187 17.2415 6.82914 16.8374C7.0147 16.6975 7.19515 16.5518 7.36941 16.402C11.66 18.396 16.4523 18.396 20.7186 16.402C20.8953 16.5518 21.0758 16.6975 21.2613 16.8374C20.5663 17.2435 19.8288 17.5947 19.0624 17.8863C19.4537 18.6713 19.9167 19.4246 20.4385 20.1417C22.5276 19.5165 24.6925 18.5135 26.9223 16.858C27.4684 11.2365 25.9961 6.22055 23.0212 1.67671ZM9.68041 13.6383C8.39754 13.6383 7.34085 12.4456 7.34085 10.994C7.34085 9.5424 8.37555 8.34973 9.68041 8.34973C10.9893 8.34973 12.0395 9.5424 12.0175 10.994C12.0175 12.4456 10.9893 13.6383 9.68041 13.6383ZM18.3161 13.6383C17.0332 13.6383 15.9765 12.4456 15.9765 10.994C15.9765 9.5424 17.0112 8.34973 18.3161 8.34973C19.625 8.34973 20.6752 9.5424 20.6532 10.994C20.6532 12.4456 19.625 13.6383 18.3161 13.6383Z"/>
</svg> </svg>
</div> </div>
</Tooltip>
</div>
<div className="server-separator" />
<div className="server-item-wrapper">
<div className={`server-pill ${view === 'server' ? 'active' : ''}`} />
<Tooltip text="Secure Chat" position="right">
<div <div
className={`server-icon ${view === 'server' ? 'active' : ''}`} className={`server-icon ${view === 'server' ? 'active' : ''}`}
onClick={() => onViewChange('server')} onClick={() => onViewChange('server')}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
>Sc</div> >Sc</div>
</Tooltip>
</div>
</div> </div>
{view === 'me' ? renderDMView() : renderServerView()} {view === 'me' ? renderDMView() : renderServerView()}
@@ -585,7 +655,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
{connectionState === 'connected' && ( {connectionState === 'connected' && (
<div style={{ <div style={{
backgroundColor: '#292b2f', backgroundColor: 'var(--panel-bg)',
borderRadius: '8px 8px 0px 0px', borderRadius: '8px 8px 0px 0px',
padding: 'calc(16px - 8px + 4px)', padding: 'calc(16px - 8px + 4px)',
margin: '8px 8px 0px 8px', margin: '8px 8px 0px 8px',
@@ -602,22 +672,23 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
background: 'transparent', border: 'none', cursor: 'pointer', padding: '0', display: 'flex', justifyContent: 'center' background: 'transparent', border: 'none', cursor: 'pointer', padding: '0', display: 'flex', justifyContent: 'center'
}} }}
> >
<ColoredIcon src={disconnectIcon} color="#b9bbbe" size="20px" /> <ColoredIcon src={disconnectIcon} color="var(--header-secondary)" size="20px" />
</button> </button>
</div> </div>
<div style={{ color: '#dcddde', fontSize: 12, marginBottom: 8 }}>{voiceChannelName} / Secure Chat</div> <div style={{ color: 'var(--text-normal)', fontSize: 12, marginBottom: 4 }}>{voiceChannelName} / Secure Chat</div>
<div style={{ marginBottom: 8 }}><VoiceTimer /></div>
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)} title="Turn On Camera" style={voicePanelButtonStyle}> <button onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)} title="Turn On Camera" style={voicePanelButtonStyle}>
<ColoredIcon src={cameraIcon} color="#b9bbbe" size="20px" /> <ColoredIcon src={cameraIcon} color="var(--header-secondary)" size="20px" />
</button> </button>
<button onClick={handleScreenShareClick} title="Share Screen" style={voicePanelButtonStyle}> <button onClick={handleScreenShareClick} title="Share Screen" style={voicePanelButtonStyle}>
<ColoredIcon src={screenIcon} color="#b9bbbe" size="20px" /> <ColoredIcon src={screenIcon} color="var(--header-secondary)" size="20px" />
</button> </button>
</div> </div>
</div> </div>
)} )}
<UserControlPanel username={username} /> <UserControlPanel username={username} userId={userId} />
{editingChannel && ( {editingChannel && (
<ChannelSettingsModal <ChannelSettingsModal

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { useTheme, THEMES, THEME_LABELS } from '../contexts/ThemeContext';
const THEME_PREVIEWS = {
[THEMES.LIGHT]: { bg: '#ffffff', sidebar: '#f2f3f5', tertiary: '#e3e5e8', text: '#313338' },
[THEMES.DARK]: { bg: '#313338', sidebar: '#2b2d31', tertiary: '#1e1f22', text: '#f2f3f5' },
[THEMES.ASH]: { bg: '#202225', sidebar: '#1a1b1e', tertiary: '#111214', text: '#f0f1f3' },
[THEMES.ONYX]: { bg: '#0c0c14', sidebar: '#080810', tertiary: '#000000', text: '#e0def0' },
};
const ThemeSelector = ({ onClose }) => {
const { theme, setTheme } = useTheme();
return (
<div className="theme-selector-overlay" onClick={onClose}>
<div className="theme-selector-modal" onClick={(e) => e.stopPropagation()}>
<div className="theme-selector-header">
<h2>Appearance</h2>
<button className="theme-selector-close" onClick={onClose}></button>
</div>
<div className="theme-selector-grid">
{Object.values(THEMES).map((themeKey) => {
const preview = THEME_PREVIEWS[themeKey];
const isActive = theme === themeKey;
return (
<div
key={themeKey}
className={`theme-card ${isActive ? 'active' : ''}`}
onClick={() => setTheme(themeKey)}
>
<div className="theme-preview" style={{ backgroundColor: preview.bg }}>
<div className="theme-preview-sidebar" style={{ backgroundColor: preview.sidebar }}>
<div className="theme-preview-channel" style={{ backgroundColor: preview.tertiary }} />
<div className="theme-preview-channel" style={{ backgroundColor: preview.tertiary, width: '60%' }} />
</div>
<div className="theme-preview-chat">
<div className="theme-preview-message" style={{ backgroundColor: preview.sidebar, opacity: 0.6 }} />
<div className="theme-preview-message" style={{ backgroundColor: preview.sidebar, opacity: 0.4, width: '70%' }} />
</div>
</div>
<div className="theme-card-label">
<div className={`theme-radio ${isActive ? 'active' : ''}`}>
{isActive && <div className="theme-radio-dot" />}
</div>
<span>{THEME_LABELS[themeKey]}</span>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};
export default ThemeSelector;

View File

@@ -0,0 +1,61 @@
import React from 'react';
const TitleBar = () => {
return (
<div className="titlebar">
<div className="titlebar-drag-region" />
<div className="titlebar-nav">
<button
className="titlebar-nav-btn"
onClick={() => window.history.back()}
aria-label="Go Back"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button>
<button
className="titlebar-nav-btn"
onClick={() => window.history.forward()}
aria-label="Go Forward"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z"/>
</svg>
</button>
</div>
<div className="titlebar-title">Discord Clone</div>
<div className="titlebar-buttons">
<button
className="titlebar-btn titlebar-minimize"
onClick={() => window.windowControls?.minimize()}
aria-label="Minimize"
>
<svg width="12" height="12" viewBox="0 0 12 12">
<rect fill="currentColor" width="10" height="1" x="1" y="6" />
</svg>
</button>
<button
className="titlebar-btn titlebar-maximize"
onClick={() => window.windowControls?.maximize()}
aria-label="Maximize"
>
<svg width="12" height="12" viewBox="0 0 12 12">
<rect fill="none" stroke="currentColor" strokeWidth="1" width="8" height="8" x="2" y="2" />
</svg>
</button>
<button
className="titlebar-btn titlebar-close"
onClick={() => window.windowControls?.close()}
aria-label="Close"
>
<svg width="12" height="12" viewBox="0 0 12 12">
<polygon fill="currentColor" points="11,1.576 10.424,1 6,5.424 1.576,1 1,1.576 5.424,6 1,10.424 1.576,11 6,6.576 10.424,11 11,10.424 6.576,6" />
</svg>
</button>
</div>
</div>
);
};
export default TitleBar;

View File

@@ -0,0 +1,69 @@
import React, { useState, useEffect, useCallback } from 'react';
import ReactDOM from 'react-dom';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
function getUserColor(name) {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
}
const ToastContainer = ({ toasts, removeToast }) => {
if (toasts.length === 0) return null;
return ReactDOM.createPortal(
<div className="toast-container">
{toasts.map(toast => (
<ToastItem key={toast.id} toast={toast} onDismiss={() => removeToast(toast.id)} />
))}
</div>,
document.body
);
};
const ToastItem = ({ toast, onDismiss }) => {
const [exiting, setExiting] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setExiting(true);
setTimeout(onDismiss, 300);
}, 5000);
return () => clearTimeout(timer);
}, [onDismiss]);
return (
<div className={`toast ${exiting ? 'toast-exit' : 'toast-enter'}`}>
<div className="toast-avatar" style={{ backgroundColor: getUserColor(toast.username || '') }}>
{(toast.username || '?').substring(0, 1).toUpperCase()}
</div>
<div className="toast-body">
<div className="toast-title">New message from <strong>{toast.username}</strong></div>
<div className="toast-preview">{toast.preview}</div>
</div>
<button className="toast-close" onClick={() => { setExiting(true); setTimeout(onDismiss, 300); }}>
&times;
</button>
</div>
);
};
export function useToasts() {
const [toasts, setToasts] = useState([]);
const addToast = useCallback((toast) => {
const id = Date.now() + Math.random();
setToasts(prev => [...prev.slice(-4), { ...toast, id }]);
}, []);
const removeToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
return { toasts, addToast, removeToast, ToastContainer: () => <ToastContainer toasts={toasts} removeToast={removeToast} /> };
}
export default ToastContainer;

View File

@@ -0,0 +1,90 @@
import React, { useState, useRef, useEffect } from 'react';
import ReactDOM from 'react-dom';
const Tooltip = ({ children, text, position = 'top' }) => {
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const triggerRef = useRef(null);
const timeoutRef = useRef(null);
const showTooltip = () => {
timeoutRef.current = setTimeout(() => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
let top, left;
switch (position) {
case 'bottom':
top = rect.bottom + 8;
left = rect.left + rect.width / 2;
break;
case 'left':
top = rect.top + rect.height / 2;
left = rect.left - 8;
break;
case 'right':
top = rect.top + rect.height / 2;
left = rect.right + 8;
break;
default: // top
top = rect.top - 8;
left = rect.left + rect.width / 2;
break;
}
setCoords({ top, left });
setVisible(true);
}
}, 200);
};
const hideTooltip = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setVisible(false);
};
useEffect(() => {
return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); };
}, []);
const getTransformStyle = () => {
switch (position) {
case 'bottom': return 'translate(-50%, 0)';
case 'left': return 'translate(-100%, -50%)';
case 'right': return 'translate(0, -50%)';
default: return 'translate(-50%, -100%)';
}
};
const getArrowClass = () => `tooltip-arrow tooltip-arrow-${position}`;
return (
<>
<div
ref={triggerRef}
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
style={{ display: 'inline-flex' }}
>
{children}
</div>
{visible && ReactDOM.createPortal(
<div
className="tooltip"
style={{
position: 'fixed',
top: coords.top,
left: coords.left,
transform: getTransformStyle(),
zIndex: 10001,
}}
>
{text}
<div className={getArrowClass()} />
</div>,
document.body
)}
</>
);
};
export default Tooltip;

View File

@@ -0,0 +1,133 @@
import React, { useRef, useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import { useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import Avatar from './Avatar';
const STATUS_LABELS = {
online: 'Online',
idle: 'Idle',
dnd: 'Do Not Disturb',
invisible: 'Invisible',
offline: 'Offline',
};
const STATUS_COLORS = {
online: '#3ba55c',
idle: '#faa61a',
dnd: '#ed4245',
invisible: '#747f8d',
offline: '#747f8d',
};
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
function getUserColor(name) {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
}
const UserProfilePopup = ({ userId, username, avatarUrl, status, position, onClose, onSendMessage }) => {
const popupRef = useRef(null);
const [note, setNote] = useState('');
// Fetch member data (roles, aboutMe) for this user
const allUsers = useQuery(api.auth.getPublicKeys) || [];
const userData = allUsers.find(u => u.id === userId);
useEffect(() => {
const handleClick = (e) => {
if (popupRef.current && !popupRef.current.contains(e.target)) {
onClose();
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [onClose]);
// Load note from localStorage
useEffect(() => {
if (userId) {
const saved = localStorage.getItem(`note_${userId}`);
if (saved) setNote(saved);
}
}, [userId]);
const handleNoteChange = (e) => {
const val = e.target.value;
setNote(val);
if (userId) {
localStorage.setItem(`note_${userId}`, val);
}
};
const userColor = getUserColor(username || 'Unknown');
const userStatus = status || 'online';
const resolvedAvatarUrl = avatarUrl || userData?.avatarUrl;
const aboutMe = userData?.aboutMe;
const style = {
position: 'fixed',
top: Math.min(position.y, window.innerHeight - 420),
left: Math.min(position.x, window.innerWidth - 320),
zIndex: 10000,
};
return ReactDOM.createPortal(
<div ref={popupRef} className="user-profile-popup" style={style}>
<div className="user-profile-banner" style={{ backgroundColor: userColor }} />
<div className="user-profile-body">
<div className="user-profile-avatar-wrapper">
<Avatar
username={username}
avatarUrl={resolvedAvatarUrl}
size={64}
className="user-profile-avatar"
style={{ border: '4px solid var(--background-base-lowest)' }}
/>
<div
className="user-profile-status-dot"
style={{ backgroundColor: STATUS_COLORS[userStatus] }}
/>
</div>
<div className="user-profile-name">{username}</div>
<div className="user-profile-status-text">
{userData?.customStatus || STATUS_LABELS[userStatus] || 'Online'}
</div>
<div className="user-profile-divider" />
<div className="user-profile-section-header">ABOUT ME</div>
<div className="user-profile-about">
{aboutMe || 'No information set.'}
</div>
<div className="user-profile-divider" />
<div className="user-profile-section-header">NOTE</div>
<textarea
className="user-profile-note-input"
placeholder="Click to add a note"
value={note}
onChange={handleNoteChange}
rows={2}
/>
{onSendMessage && (
<button
className="user-profile-message-btn"
onClick={() => { onSendMessage(userId, username); onClose(); }}
style={{ marginTop: '8px' }}
>
Send Message
</button>
)}
</div>
</div>,
document.body
);
};
export default UserProfilePopup;

View File

@@ -0,0 +1,42 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
const STORAGE_KEY = 'discord-theme';
export const THEMES = {
LIGHT: 'theme-light',
DARK: 'theme-dark',
ASH: 'theme-darker',
ONYX: 'theme-midnight',
};
export const THEME_LABELS = {
[THEMES.LIGHT]: 'Light',
[THEMES.DARK]: 'Dark',
[THEMES.ASH]: 'Ash',
[THEMES.ONYX]: 'Onyx',
};
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => {
return localStorage.getItem(STORAGE_KEY) || THEMES.DARK;
});
useEffect(() => {
document.documentElement.className = theme;
localStorage.setItem(STORAGE_KEY, theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme, THEMES, THEME_LABELS }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,20 +3,26 @@ import ReactDOM from 'react-dom/client';
import { HashRouter } from 'react-router-dom'; import { HashRouter } from 'react-router-dom';
import { ConvexProvider, ConvexReactClient } from 'convex/react'; import { ConvexProvider, ConvexReactClient } from 'convex/react';
import App from './App'; import App from './App';
import './styles/themes.css';
import './index.css'; import './index.css';
import { ThemeProvider } from './contexts/ThemeContext';
import { VoiceProvider } from './contexts/VoiceContext'; import { VoiceProvider } from './contexts/VoiceContext';
import TitleBar from './components/TitleBar';
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL); const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider>
<ConvexProvider client={convex}> <ConvexProvider client={convex}>
<VoiceProvider> <VoiceProvider>
<TitleBar />
<HashRouter> <HashRouter>
<App /> <App />
</HashRouter> </HashRouter>
</VoiceProvider> </VoiceProvider>
</ConvexProvider> </ConvexProvider>
</ThemeProvider>
</React.StrictMode>, </React.StrictMode>,
); );

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useQuery, useConvex } from 'convex/react'; import { useQuery, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api'; import { api } from '../../../../convex/_generated/api';
import Sidebar from '../components/Sidebar'; import Sidebar from '../components/Sidebar';
@@ -6,6 +6,9 @@ import ChatArea from '../components/ChatArea';
import VoiceStage from '../components/VoiceStage'; import VoiceStage from '../components/VoiceStage';
import { useVoice } from '../contexts/VoiceContext'; import { useVoice } from '../contexts/VoiceContext';
import FriendsView from '../components/FriendsView'; import FriendsView from '../components/FriendsView';
import MembersList from '../components/MembersList';
import ChatHeader from '../components/ChatHeader';
import { useToasts } from '../components/Toast';
const Chat = () => { const Chat = () => {
const [view, setView] = useState('server'); const [view, setView] = useState('server');
@@ -14,8 +17,29 @@ const Chat = () => {
const [userId, setUserId] = useState(null); const [userId, setUserId] = useState(null);
const [channelKeys, setChannelKeys] = useState({}); const [channelKeys, setChannelKeys] = useState({});
const [activeDMChannel, setActiveDMChannel] = useState(null); const [activeDMChannel, setActiveDMChannel] = useState(null);
const [showMembers, setShowMembers] = useState(true);
const [showPinned, setShowPinned] = useState(false);
const convex = useConvex(); const convex = useConvex();
const { toasts, addToast, removeToast, ToastContainer } = useToasts();
const prevDmChannelsRef = useRef(null);
const { toggleMute } = useVoice();
// Keyboard shortcuts
useEffect(() => {
const handler = (e) => {
if (e.ctrlKey && e.key === 'k') {
e.preventDefault();
// Quick switcher placeholder - could open a search modal
}
if (e.ctrlKey && e.shiftKey && e.key === 'M') {
e.preventDefault();
toggleMute();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [toggleMute]);
const channels = useQuery(api.channels.list) || []; const channels = useQuery(api.channels.list) || [];
@@ -113,20 +137,46 @@ const Chat = () => {
} }
}, [convex]); }, [convex]);
const handleSelectChannel = useCallback((channelId) => {
setActiveChannel(channelId);
setShowPinned(false);
}, []);
const activeChannelObj = channels.find(c => c._id === activeChannel); const activeChannelObj = channels.find(c => c._id === activeChannel);
const { room, voiceStates } = useVoice(); const { room, voiceStates } = useVoice();
const isDMView = view === 'me' && activeDMChannel;
const isServerTextChannel = view === 'server' && activeChannel && activeChannelObj?.type !== 'voice';
const currentChannelId = isDMView ? activeDMChannel.channel_id : activeChannel;
function renderMainContent() { function renderMainContent() {
if (view === 'me') { if (view === 'me') {
if (activeDMChannel) { if (activeDMChannel) {
return ( return (
<div className="chat-container">
<ChatHeader
channelName={activeDMChannel.other_username}
channelType="dm"
onToggleMembers={() => {}}
showMembers={false}
onTogglePinned={() => setShowPinned(p => !p)}
/>
<div className="chat-content">
<ChatArea <ChatArea
channelId={activeDMChannel.channel_id} channelId={activeDMChannel.channel_id}
channelName={activeDMChannel.other_username} channelName={activeDMChannel.other_username}
channelType="dm"
channelKey={channelKeys[activeDMChannel.channel_id]} channelKey={channelKeys[activeDMChannel.channel_id]}
username={username} username={username}
userId={userId} userId={userId}
showMembers={false}
onToggleMembers={() => {}}
onOpenDM={openDM}
showPinned={showPinned}
onTogglePinned={() => setShowPinned(false)}
/> />
</div>
</div>
); );
} }
return <FriendsView onOpenDM={openDM} />; return <FriendsView onOpenDM={openDM} />;
@@ -137,13 +187,37 @@ const Chat = () => {
return <VoiceStage room={room} channelId={activeChannel} voiceStates={voiceStates} channelName={activeChannelObj?.name} />; return <VoiceStage room={room} channelId={activeChannel} voiceStates={voiceStates} channelName={activeChannelObj?.name} />;
} }
return ( return (
<div className="chat-container">
<ChatHeader
channelName={activeChannelObj?.name || activeChannel}
channelType="text"
channelTopic={activeChannelObj?.topic}
onToggleMembers={() => setShowMembers(!showMembers)}
showMembers={showMembers}
onTogglePinned={() => setShowPinned(p => !p)}
serverName="Secure Chat"
/>
<div className="chat-content">
<ChatArea <ChatArea
channelId={activeChannel} channelId={activeChannel}
channelName={activeChannelObj?.name || activeChannel} channelName={activeChannelObj?.name || activeChannel}
channelType="text"
channelKey={channelKeys[activeChannel]} channelKey={channelKeys[activeChannel]}
username={username} username={username}
userId={userId} userId={userId}
showMembers={showMembers}
onToggleMembers={() => setShowMembers(!showMembers)}
onOpenDM={openDM}
showPinned={showPinned}
onTogglePinned={() => setShowPinned(false)}
/> />
<MembersList
channelId={activeChannel}
visible={showMembers}
onMemberClick={(member) => {}}
/>
</div>
</div>
); );
} }
@@ -161,7 +235,7 @@ const Chat = () => {
<Sidebar <Sidebar
channels={channels} channels={channels}
activeChannel={activeChannel} activeChannel={activeChannel}
onSelectChannel={setActiveChannel} onSelectChannel={handleSelectChannel}
username={username} username={username}
channelKeys={channelKeys} channelKeys={channelKeys}
view={view} view={view}
@@ -170,8 +244,10 @@ const Chat = () => {
activeDMChannel={activeDMChannel} activeDMChannel={activeDMChannel}
setActiveDMChannel={setActiveDMChannel} setActiveDMChannel={setActiveDMChannel}
dmChannels={dmChannels} dmChannels={dmChannels}
userId={userId}
/> />
{renderMainContent()} {renderMainContent()}
<ToastContainer />
</div> </div>
); );
}; };

View File

@@ -0,0 +1,360 @@
/* ============================================
Discord Theme System
4 themes: Light, Dark (default), Ash, Onyx
CSS class mapping:
Light → .theme-light
Dark → .theme-dark
Ash → .theme-darker
Onyx → .theme-midnight
============================================ */
/* ============================================
DARK THEME (default)
============================================ */
.theme-dark {
/* Backgrounds */
--background-base-low: #313338;
--background-base-lower: #2b2d31;
--background-base-lowest: #1e1f22;
--background-surface-high: #3c3e44;
--background-surface-higher: #3f4147;
--background-surface-highest: #43454b;
--chat-background: #313338;
--channeltextarea-background: #383a40;
--modal-background: #313338;
--panel-bg: #2b2d31;
--embed-background: #2f3136;
/* Text */
--text-default: #f2f3f5;
--text-strong: #f2f3f5;
--text-muted: #949ba4;
--text-subtle: #b5bac1;
--text-link: #00a8fc;
--channels-default: #949ba4;
--text-feedback-critical: #ed4245;
/* Interactive */
--interactive-background-hover: rgba(78, 80, 88, 0.3);
--interactive-background-active: rgba(78, 80, 88, 0.48);
--interactive-background-selected: rgba(78, 80, 88, 0.6);
--interactive-icon-default: #b5bac1;
--interactive-icon-hover: #dbdee1;
--interactive-icon-active: #ffffff;
--interactive-text-default: #b5bac1;
--interactive-text-hover: #dbdee1;
--interactive-text-active: #ffffff;
/* Borders */
--border-subtle: #1e1f22;
--border-muted: rgba(255, 255, 255, 0.04);
--border-normal: rgba(255, 255, 255, 0.2);
--border-strong: rgba(255, 255, 255, 0.44);
/* Icons */
--icon-default: #dbdee1;
--icon-strong: #ffffff;
--icon-muted: #949ba4;
--icon-subtle: #b5bac1;
/* Controls */
--control-primary-background-default: #5865f2;
--control-primary-background-hover: #4752c4;
--control-primary-background-active: #3b43a8;
--control-critical-primary-background-default: #ed4245;
/* Input */
--input-background-default: #383a40;
--input-border-default: rgba(255, 255, 255, 0.2);
--input-text-default: #dbdee1;
/* Scrollbar */
--scrollbar-auto-thumb: #1a1b1e;
--scrollbar-thin-thumb: #1a1b1e;
/* Message */
--message-background-hover: rgba(0, 0, 0, 0.06);
/* Compatibility aliases (map old names → new semantic names) */
--bg-primary: #313338;
--bg-secondary: #2b2d31;
--bg-tertiary: #1e1f22;
--text-normal: #dbdee1;
--header-primary: #f2f3f5;
--header-secondary: #b5bac1;
--interactive-normal: #b5bac1;
--interactive-hover: #dbdee1;
--interactive-active: #ffffff;
--brand-experiment: #5865f2;
--brand-experiment-hover: #4752c4;
--input-background: #383a40;
--danger: #ed4245;
--background-modifier-hover: rgba(78, 80, 88, 0.3);
--background-modifier-active: rgba(78, 80, 88, 0.48);
--background-modifier-selected: rgba(78, 80, 88, 0.6);
--div-border: #1e1f22;
}
/* ============================================
LIGHT THEME
============================================ */
.theme-light {
/* Backgrounds */
--background-base-low: #ffffff;
--background-base-lower: #f2f3f5;
--background-base-lowest: #e3e5e8;
--background-surface-high: #ffffff;
--background-surface-higher: #ffffff;
--background-surface-highest: #ffffff;
--chat-background: #ffffff;
--channeltextarea-background: #ebedef;
--modal-background: #ffffff;
--panel-bg: #f2f3f5;
--embed-background: #f2f3f5;
/* Text */
--text-default: #313338;
--text-strong: #060607;
--text-muted: #5c6470;
--text-subtle: #4e5058;
--text-link: #006ce7;
--channels-default: #5c6470;
--text-feedback-critical: #da373c;
/* Interactive */
--interactive-background-hover: rgba(116, 124, 138, 0.14);
--interactive-background-active: rgba(116, 124, 138, 0.22);
--interactive-background-selected: rgba(116, 124, 138, 0.30);
--interactive-icon-default: #4e5058;
--interactive-icon-hover: #313338;
--interactive-icon-active: #060607;
--interactive-text-default: #4e5058;
--interactive-text-hover: #313338;
--interactive-text-active: #060607;
/* Borders */
--border-subtle: rgba(0, 0, 0, 0.28);
--border-muted: rgba(0, 0, 0, 0.2);
--border-normal: rgba(0, 0, 0, 0.36);
--border-strong: rgba(0, 0, 0, 0.48);
/* Icons */
--icon-default: #313338;
--icon-strong: #060607;
--icon-muted: #5c6470;
--icon-subtle: #4e5058;
/* Controls */
--control-primary-background-default: #5865f2;
--control-primary-background-hover: #4752c4;
--control-primary-background-active: #3b43a8;
--control-critical-primary-background-default: #da373c;
/* Input */
--input-background-default: #e3e5e8;
--input-border-default: rgba(0, 0, 0, 0.36);
--input-text-default: #313338;
/* Scrollbar */
--scrollbar-auto-thumb: #c1c3c7;
--scrollbar-thin-thumb: #c1c3c7;
/* Message */
--message-background-hover: rgba(0, 0, 0, 0.06);
/* Compatibility aliases */
--bg-primary: #ffffff;
--bg-secondary: #f2f3f5;
--bg-tertiary: #e3e5e8;
--text-normal: #313338;
--header-primary: #060607;
--header-secondary: #4e5058;
--interactive-normal: #4e5058;
--interactive-hover: #313338;
--interactive-active: #060607;
--brand-experiment: #5865f2;
--brand-experiment-hover: #4752c4;
--input-background: #e3e5e8;
--danger: #da373c;
--background-modifier-hover: rgba(116, 124, 138, 0.14);
--background-modifier-active: rgba(116, 124, 138, 0.22);
--background-modifier-selected: rgba(116, 124, 138, 0.30);
--div-border: #e1e2e4;
}
/* ============================================
ASH THEME (theme-darker)
============================================ */
.theme-darker {
/* Backgrounds */
--background-base-low: #202225;
--background-base-lower: #1a1b1e;
--background-base-lowest: #111214;
--background-surface-high: #292b2f;
--background-surface-higher: #2e3035;
--background-surface-highest: #33363c;
--chat-background: #202225;
--channeltextarea-background: #252529;
--modal-background: #292b2f;
--panel-bg: #1a1b1e;
--embed-background: #242529;
/* Text */
--text-default: #f0f1f3;
--text-strong: #f5f5f7;
--text-muted: #858993;
--text-subtle: #a0a4ad;
--text-link: #00a8fc;
--channels-default: #858993;
--text-feedback-critical: #ed4245;
/* Interactive */
--interactive-background-hover: rgba(78, 80, 88, 0.15);
--interactive-background-active: rgba(78, 80, 88, 0.3);
--interactive-background-selected: rgba(78, 80, 88, 0.4);
--interactive-icon-default: #a0a4ad;
--interactive-icon-hover: #dddfe4;
--interactive-icon-active: #f5f5f7;
--interactive-text-default: #a0a4ad;
--interactive-text-hover: #dddfe4;
--interactive-text-active: #f5f5f7;
/* Borders */
--border-subtle: rgba(255, 255, 255, 0.12);
--border-muted: rgba(255, 255, 255, 0.04);
--border-normal: rgba(255, 255, 255, 0.2);
--border-strong: rgba(255, 255, 255, 0.44);
/* Icons */
--icon-default: #dddfe4;
--icon-strong: #f5f5f7;
--icon-muted: #858993;
--icon-subtle: #a0a4ad;
/* Controls */
--control-primary-background-default: #5865f2;
--control-primary-background-hover: #4752c4;
--control-primary-background-active: #3b43a8;
--control-critical-primary-background-default: #ed4245;
/* Input */
--input-background-default: #252529;
--input-border-default: rgba(255, 255, 255, 0.2);
--input-text-default: #dddfe4;
/* Scrollbar */
--scrollbar-auto-thumb: #15161a;
--scrollbar-thin-thumb: #15161a;
/* Message */
--message-background-hover: rgba(0, 0, 0, 0.08);
/* Compatibility aliases */
--bg-primary: #202225;
--bg-secondary: #1a1b1e;
--bg-tertiary: #111214;
--text-normal: #dddfe4;
--header-primary: #f5f5f7;
--header-secondary: #a0a4ad;
--interactive-normal: #a0a4ad;
--interactive-hover: #dddfe4;
--interactive-active: #f5f5f7;
--brand-experiment: #5865f2;
--brand-experiment-hover: #4752c4;
--input-background: #252529;
--danger: #ed4245;
--background-modifier-hover: rgba(78, 80, 88, 0.15);
--background-modifier-active: rgba(78, 80, 88, 0.3);
--background-modifier-selected: rgba(78, 80, 88, 0.4);
--div-border: #111214;
}
/* ============================================
ONYX THEME (theme-midnight)
============================================ */
.theme-midnight {
/* Backgrounds */
--background-base-low: #0c0c14;
--background-base-lower: #080810;
--background-base-lowest: #000000;
--background-surface-high: #141422;
--background-surface-higher: #1a1a2e;
--background-surface-highest: #202038;
--chat-background: #000000;
--channeltextarea-background: #1a1a2e;
--modal-background: #141422;
--panel-bg: #0c0c14;
--embed-background: #161626;
/* Text */
--text-default: #e0def0;
--text-strong: #f8f8fc;
--text-muted: #7a7687;
--text-subtle: #a8a5b5;
--text-link: #00a8fc;
--channels-default: #7a7687;
--text-feedback-critical: #ed4245;
/* Interactive */
--interactive-background-hover: rgba(78, 73, 106, 0.2);
--interactive-background-active: rgba(78, 73, 106, 0.36);
--interactive-background-selected: rgba(78, 73, 106, 0.48);
--interactive-icon-default: #a8a5b5;
--interactive-icon-hover: #e0def0;
--interactive-icon-active: #f8f8fc;
--interactive-text-default: #a8a5b5;
--interactive-text-hover: #e0def0;
--interactive-text-active: #f8f8fc;
/* Borders */
--border-subtle: rgba(255, 255, 255, 0.2);
--border-muted: rgba(255, 255, 255, 0.16);
--border-normal: rgba(255, 255, 255, 0.24);
--border-strong: rgba(255, 255, 255, 0.44);
/* Icons */
--icon-default: #e0def0;
--icon-strong: #f8f8fc;
--icon-muted: #7a7687;
--icon-subtle: #a8a5b5;
/* Controls */
--control-primary-background-default: #5865f2;
--control-primary-background-hover: #4752c4;
--control-primary-background-active: #3b43a8;
--control-critical-primary-background-default: #ed4245;
/* Input */
--input-background-default: #1a1a2e;
--input-border-default: rgba(255, 255, 255, 0.24);
--input-text-default: #e0def0;
/* Scrollbar */
--scrollbar-auto-thumb: #1a1a2e;
--scrollbar-thin-thumb: #1a1a2e;
/* Message */
--message-background-hover: rgba(0, 0, 0, 0.12);
/* Compatibility aliases */
--bg-primary: #0c0c14;
--bg-secondary: #080810;
--bg-tertiary: #000000;
--text-normal: #e0def0;
--header-primary: #f8f8fc;
--header-secondary: #a8a5b5;
--interactive-normal: #a8a5b5;
--interactive-hover: #e0def0;
--interactive-active: #f8f8fc;
--brand-experiment: #5865f2;
--brand-experiment-hover: #4752c4;
--input-background: #1a1a2e;
--danger: #ed4245;
--background-modifier-hover: rgba(78, 73, 106, 0.2);
--background-modifier-active: rgba(78, 73, 106, 0.36);
--background-modifier-selected: rgba(78, 73, 106, 0.48);
--div-border: #080810;
}

7
TODO.md Normal file
View File

@@ -0,0 +1,7 @@
- Create auto updater for app
- Save app x and y position on close to a settings.json file
- Save app width and height on close to a settings.json file
- Save app theme on close to a settings.json file

View File

@@ -15,6 +15,7 @@ import type * as dms from "../dms.js";
import type * as files from "../files.js"; import type * as files from "../files.js";
import type * as gifs from "../gifs.js"; import type * as gifs from "../gifs.js";
import type * as invites from "../invites.js"; import type * as invites from "../invites.js";
import type * as members from "../members.js";
import type * as messages from "../messages.js"; import type * as messages from "../messages.js";
import type * as reactions from "../reactions.js"; import type * as reactions from "../reactions.js";
import type * as roles from "../roles.js"; import type * as roles from "../roles.js";
@@ -36,6 +37,7 @@ declare const fullApi: ApiFromModules<{
files: typeof files; files: typeof files;
gifs: typeof gifs; gifs: typeof gifs;
invites: typeof invites; invites: typeof invites;
members: typeof members;
messages: typeof messages; messages: typeof messages;
reactions: typeof reactions; reactions: typeof reactions;
roles: typeof roles; roles: typeof roles;

View File

@@ -197,14 +197,62 @@ export const getPublicKeys = query({
id: v.string(), id: v.string(),
username: v.string(), username: v.string(),
public_identity_key: v.string(), public_identity_key: v.string(),
status: v.optional(v.string()),
avatarUrl: v.optional(v.union(v.string(), v.null())),
aboutMe: v.optional(v.string()),
customStatus: v.optional(v.string()),
}) })
), ),
handler: async (ctx) => { handler: async (ctx) => {
const users = await ctx.db.query("userProfiles").collect(); const users = await ctx.db.query("userProfiles").collect();
return users.map((u) => ({ const results = [];
for (const u of users) {
let avatarUrl: string | null = null;
if (u.avatarStorageId) {
avatarUrl = await ctx.storage.getUrl(u.avatarStorageId);
}
results.push({
id: u._id, id: u._id,
username: u.username, username: u.username,
public_identity_key: u.publicIdentityKey, public_identity_key: u.publicIdentityKey,
})); status: u.status || "online",
avatarUrl,
aboutMe: u.aboutMe,
customStatus: u.customStatus,
});
}
return results;
},
});
// Update user profile (aboutMe, avatar, customStatus)
export const updateProfile = mutation({
args: {
userId: v.id("userProfiles"),
aboutMe: v.optional(v.string()),
avatarStorageId: v.optional(v.id("_storage")),
customStatus: v.optional(v.string()),
},
returns: v.null(),
handler: async (ctx, args) => {
const patch: Record<string, unknown> = {};
if (args.aboutMe !== undefined) patch.aboutMe = args.aboutMe;
if (args.avatarStorageId !== undefined) patch.avatarStorageId = args.avatarStorageId;
if (args.customStatus !== undefined) patch.customStatus = args.customStatus;
await ctx.db.patch(args.userId, patch);
return null;
},
});
// Update user status
export const updateStatus = mutation({
args: {
userId: v.id("userProfiles"),
status: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.userId, { status: args.status });
return null;
}, },
}); });

View File

@@ -31,13 +31,16 @@ 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()),
topic: v.optional(v.string()),
position: v.optional(v.number()),
}) })
), ),
handler: async (ctx) => { handler: async (ctx) => {
const channels = await ctx.db.query("channels").collect(); const channels = await ctx.db.query("channels").collect();
return channels return channels
.filter((c) => c.type !== "dm") .filter((c) => c.type !== "dm")
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => (a.position ?? 0) - (b.position ?? 0) || a.name.localeCompare(b.name));
}, },
}); });
@@ -50,6 +53,9 @@ 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()),
topic: v.optional(v.string()),
position: v.optional(v.number()),
}), }),
v.null() v.null()
), ),
@@ -63,6 +69,9 @@ 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()),
topic: v.optional(v.string()),
position: v.optional(v.number()),
}, },
returns: v.object({ id: v.id("channels") }), returns: v.object({ id: v.id("channels") }),
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -82,12 +91,30 @@ export const create = mutation({
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,
topic: args.topic,
position: args.position,
}); });
return { id }; return { id };
}, },
}); });
// Update channel topic
export const updateTopic = mutation({
args: {
id: v.id("channels"),
topic: v.string(),
},
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, { topic: args.topic });
return null;
},
});
// Rename channel // Rename channel
export const rename = mutation({ export const rename = mutation({
args: { args: {
@@ -99,6 +126,9 @@ 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()),
topic: v.optional(v.string()),
position: v.optional(v.number()),
}), }),
handler: async (ctx, args) => { handler: async (ctx, args) => {
if (!args.name.trim()) { if (!args.name.trim()) {

View File

@@ -49,6 +49,7 @@ export const listDMs = query({
channel_name: v.string(), channel_name: v.string(),
other_user_id: v.string(), other_user_id: v.string(),
other_username: v.string(), other_username: v.string(),
other_user_status: v.optional(v.string()),
}) })
), ),
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -78,6 +79,7 @@ export const listDMs = query({
channel_name: channel.name, channel_name: channel.name,
other_user_id: otherUser._id as string, other_user_id: otherUser._id as string,
other_username: otherUser.username, other_username: otherUser.username,
other_user_status: otherUser.status || "online",
}; };
}) })
); );

63
convex/members.ts Normal file
View File

@@ -0,0 +1,63 @@
import { query } from "./_generated/server";
import { v } from "convex/values";
export const getChannelMembers = query({
args: {
channelId: v.id("channels"),
},
returns: v.any(),
handler: async (ctx, args) => {
const channelKeyDocs = await ctx.db
.query("channelKeys")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.collect();
const seenUsers = new Set<string>();
const members = [];
for (const doc of channelKeyDocs) {
const odId = doc.userId.toString();
if (seenUsers.has(odId)) continue;
seenUsers.add(odId);
const user = await ctx.db.get(doc.userId);
if (!user) continue;
const userRoleDocs = await ctx.db
.query("userRoles")
.withIndex("by_user", (q) => q.eq("userId", doc.userId))
.collect();
const roles = [];
for (const ur of userRoleDocs) {
const role = await ctx.db.get(ur.roleId);
if (role) {
roles.push({
id: role._id,
name: role.name,
color: role.color,
position: role.position,
isHoist: role.isHoist,
});
}
}
let avatarUrl: string | null = null;
if (user.avatarStorageId) {
avatarUrl = await ctx.storage.getUrl(user.avatarStorageId);
}
members.push({
id: user._id,
username: user.username,
status: user.status || "online",
roles: roles.sort((a, b) => b.position - a.position),
avatarUrl,
aboutMe: user.aboutMe,
customStatus: user.customStatus,
});
}
return members;
},
});

View File

@@ -20,6 +20,11 @@ export const list = query({
result.page.map(async (msg) => { result.page.map(async (msg) => {
const sender = await ctx.db.get(msg.senderId); const sender = await ctx.db.get(msg.senderId);
let avatarUrl: string | null = null;
if (sender?.avatarStorageId) {
avatarUrl = await ctx.storage.getUrl(sender.avatarStorageId);
}
const reactionDocs = await ctx.db const reactionDocs = await ctx.db
.query("messageReactions") .query("messageReactions")
.withIndex("by_message", (q) => q.eq("messageId", msg._id)) .withIndex("by_message", (q) => q.eq("messageId", msg._id))
@@ -34,6 +39,23 @@ export const list = query({
} }
} }
let replyToUsername: string | null = null;
let replyToContent: string | null = null;
let replyToNonce: string | null = null;
let replyToAvatarUrl: string | null = null;
if (msg.replyTo) {
const repliedMsg = await ctx.db.get(msg.replyTo);
if (repliedMsg) {
const repliedSender = await ctx.db.get(repliedMsg.senderId);
replyToUsername = repliedSender?.username || "Unknown";
replyToContent = repliedMsg.ciphertext;
replyToNonce = repliedMsg.nonce;
if (repliedSender?.avatarStorageId) {
replyToAvatarUrl = await ctx.storage.getUrl(repliedSender.avatarStorageId);
}
}
}
return { return {
id: msg._id, id: msg._id,
channel_id: msg.channelId, channel_id: msg.channelId,
@@ -45,7 +67,15 @@ export const list = query({
created_at: new Date(msg._creationTime).toISOString(), created_at: new Date(msg._creationTime).toISOString(),
username: sender?.username || "Unknown", username: sender?.username || "Unknown",
public_signing_key: sender?.publicSigningKey || "", public_signing_key: sender?.publicSigningKey || "",
avatarUrl,
reactions: Object.keys(reactions).length > 0 ? reactions : null, reactions: Object.keys(reactions).length > 0 ? reactions : null,
replyToId: msg.replyTo || null,
replyToUsername,
replyToContent,
replyToNonce,
replyToAvatarUrl,
editedAt: msg.editedAt || null,
pinned: msg.pinned || false,
}; };
}) })
); );
@@ -62,6 +92,7 @@ export const send = mutation({
nonce: v.string(), nonce: v.string(),
signature: v.string(), signature: v.string(),
keyVersion: v.number(), keyVersion: v.number(),
replyTo: v.optional(v.id("messages")),
}, },
returns: v.object({ id: v.id("messages") }), returns: v.object({ id: v.id("messages") }),
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -72,11 +103,74 @@ export const send = mutation({
nonce: args.nonce, nonce: args.nonce,
signature: args.signature, signature: args.signature,
keyVersion: args.keyVersion, keyVersion: args.keyVersion,
replyTo: args.replyTo,
}); });
return { id }; return { id };
}, },
}); });
export const edit = mutation({
args: {
id: v.id("messages"),
ciphertext: v.string(),
nonce: v.string(),
signature: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.id, {
ciphertext: args.ciphertext,
nonce: args.nonce,
signature: args.signature,
editedAt: Date.now(),
});
return null;
},
});
export const pin = mutation({
args: {
id: v.id("messages"),
pinned: v.boolean(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.id, { pinned: args.pinned });
return null;
},
});
export const listPinned = query({
args: {
channelId: v.id("channels"),
},
returns: v.any(),
handler: async (ctx, args) => {
const allMessages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.collect();
const pinned = allMessages.filter((m) => m.pinned === true);
return Promise.all(
pinned.map(async (msg) => {
const sender = await ctx.db.get(msg.senderId);
return {
id: msg._id,
ciphertext: msg.ciphertext,
nonce: msg.nonce,
signature: msg.signature,
key_version: msg.keyVersion,
created_at: new Date(msg._creationTime).toISOString(),
username: sender?.username || "Unknown",
public_signing_key: sender?.publicSigningKey || "",
};
})
);
},
});
export const remove = mutation({ export const remove = mutation({
args: { id: v.id("messages") }, args: { id: v.id("messages") },
returns: v.null(), returns: v.null(),

View File

@@ -11,11 +11,18 @@ export default defineSchema({
publicSigningKey: v.string(), publicSigningKey: v.string(),
encryptedPrivateKeys: v.string(), encryptedPrivateKeys: v.string(),
isAdmin: v.boolean(), isAdmin: v.boolean(),
status: v.optional(v.string()),
avatarStorageId: v.optional(v.id("_storage")),
aboutMe: v.optional(v.string()),
customStatus: v.optional(v.string()),
}).index("by_username", ["username"]), }).index("by_username", ["username"]),
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()),
topic: v.optional(v.string()),
position: v.optional(v.number()),
}).index("by_name", ["name"]), }).index("by_name", ["name"]),
messages: defineTable({ messages: defineTable({
@@ -25,6 +32,9 @@ export default defineSchema({
nonce: v.string(), nonce: v.string(),
signature: v.string(), signature: v.string(),
keyVersion: v.number(), keyVersion: v.number(),
replyTo: v.optional(v.id("messages")),
editedAt: v.optional(v.number()),
pinned: v.optional(v.boolean()),
}).index("by_channel", ["channelId"]), }).index("by_channel", ["channelId"]),
messageReactions: defineTable({ messageReactions: defineTable({

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,160 @@
.loadingWrapper__5a143 {
align-items: center;
border-radius: 2px;
display: flex;
flex-direction: row;
height: 16px;
justify-content: center;
margin: 2px 0;
padding: 8px 4px
}
.list_c47777 {
max-height: 500px
}
.actionContainer_bc4513 {
background-color: var(--background-mod-normal);
border-radius: 80px;
flex-direction: row;
padding-inline:6px 8px;padding-bottom: 4px;
padding-top: 4px
}
.actionContainer_bc4513,.actionIconContainer_bc4513 {
align-items: center;
display: flex;
justify-content: center
}
.actionIconContainer_bc4513 {
height: 16px;
margin-inline-end:4px;width: 16px
}
.actionIcon_bc4513 {
color: var(--text-muted)
}
.actionTextContainer_bc4513 {
flex: 1
}
.actionTextHeader_bc4513 {
word-wrap: normal;
text-transform: lowercase
}
.actionTextHelper_bc4513 {
margin-inline-start:4px;text-transform: lowercase
}
.emoji_ab6c65 {
-o-object-fit: contain;
object-fit: contain
}
.pro__30cbe {
text-transform: uppercase
}
.tip__30cbe {
line-height: 16px;
opacity: 1
}
.block__30cbe .pro__30cbe,.block__30cbe .tip__30cbe,.tip__30cbe {
font-size: 14px
}
.inline__30cbe .pro__30cbe,.inline__30cbe .tip__30cbe {
display: inline;
font-size: 12px
}
.inline__30cbe .pro__30cbe {
margin-inline-end:3px}
.enable-forced-colors .tip__30cbe {
opacity: 1
}
.spacing_fd14e0 {
margin-bottom: 20px
}
.spacingTop_fd14e0 {
margin-top: 20px
}
.message_fd14e0 {
background-color: var(--background-base-low);
border-radius: 3px;
box-shadow: var(--legacy-elevation-border),var(--legacy-elevation-high);
overflow: hidden;
padding-bottom: 10px;
padding-top: 10px;
pointer-events: none;
position: relative
}
.closeButton_fd14e0 {
justify-content: flex-end
}
.wrapper_f563df {
display: grid;
gap: 4px;
grid-template-columns: repeat(4,1fr);
grid-template-rows: 1fr;
justify-items: center;
padding: 8px
}
.button_f563df,.wrapper_f563df {
align-items: center
}
.button_f563df {
background-color: var(--background-mod-subtle);
border-radius: 8px;
cursor: pointer;
display: flex;
flex: 0 0 auto;
height: 44px;
justify-content: center;
padding: 0;
width: 44px
}
.button_f563df:hover {
background-color: var(--background-mod-strong)
}
.button_f563df:active {
background-color: var(--background-mod-muted)
}
.button_f563df:hover {
background-color: var(--background-mod-normal)
}
.keyboard-mode .button_f563df.focused_f563df {
background-color: var(--background-mod-normal);
box-shadow: 0 0 0 2px var(--blue-345)
}
.icon_f563df {
display: block;
height: 20px;
-o-object-fit: contain;
object-fit: contain;
width: 20px
}
.flagIcon__45b6e {
height: 12px;
width: 16px
}
/*# sourceMappingURL=59c6d21704b78874.css.map*/

View File

@@ -0,0 +1,13 @@
.highlight-mana-components [data-mana-component] {
box-shadow: 0 0 6px 2px var(--pink-51),0 0 8px 4px var(--opacity-blurple-80)
}
.highlight-mana-components [data-mana-component=text-area],.highlight-mana-components [data-mana-component=text-input] {
box-shadow: 0 0 6px 2px var(--pink-51),0 0 8px 4px var(--opacity-blurple-80)
}
.highlight-mana-buttons [data-mana-component=button] {
box-shadow: 0 0 6px 2px var(--pink-51),0 0 8px 4px var(--opacity-blurple-80)
}
/*# sourceMappingURL=6f7713d5b10d7cb3.css.map*/

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,212 @@
.DraftEditor-editorContainer,.DraftEditor-root,.public-DraftEditor-content {
height: inherit;
text-align: initial
}
.public-DraftEditor-content[contenteditable=true] {
-webkit-user-modify: read-write-plaintext-only
}
.DraftEditor-root {
position: relative
}
.DraftEditor-editorContainer {
background-color: hsla(0,0%,100%,0);
border-left: .1px solid transparent;
position: relative;
z-index: 1
}
.public-DraftEditor-block {
position: relative
}
.DraftEditor-alignLeft .public-DraftStyleDefault-block {
text-align: left
}
.DraftEditor-alignLeft .public-DraftEditorPlaceholder-root {
left: 0;
text-align: left
}
.DraftEditor-alignCenter .public-DraftStyleDefault-block {
text-align: center
}
.DraftEditor-alignCenter .public-DraftEditorPlaceholder-root {
margin: 0 auto;
text-align: center;
width: 100%
}
.DraftEditor-alignRight .public-DraftStyleDefault-block {
text-align: right
}
.DraftEditor-alignRight .public-DraftEditorPlaceholder-root {
right: 0;
text-align: right
}
.public-DraftEditorPlaceholder-root {
color: #9197a3;
position: absolute;
z-index: 1
}
.public-DraftEditorPlaceholder-hasFocus {
color: #bdc1c9
}
.DraftEditorPlaceholder-hidden {
display: none
}
.public-DraftStyleDefault-block {
position: relative;
white-space: pre-wrap
}
.public-DraftStyleDefault-ltr {
direction: ltr;
text-align: left
}
.public-DraftStyleDefault-rtl {
direction: rtl;
text-align: right
}
.public-DraftStyleDefault-listLTR {
direction: ltr
}
.public-DraftStyleDefault-listRTL {
direction: rtl
}
.public-DraftStyleDefault-ol,.public-DraftStyleDefault-ul {
margin: 16px 0;
padding: 0
}
.public-DraftStyleDefault-depth0.public-DraftStyleDefault-listLTR {
margin-left: 1.5em
}
.public-DraftStyleDefault-depth0.public-DraftStyleDefault-listRTL {
margin-right: 1.5em
}
.public-DraftStyleDefault-depth1.public-DraftStyleDefault-listLTR {
margin-left: 3em
}
.public-DraftStyleDefault-depth1.public-DraftStyleDefault-listRTL {
margin-right: 3em
}
.public-DraftStyleDefault-depth2.public-DraftStyleDefault-listLTR {
margin-left: 4.5em
}
.public-DraftStyleDefault-depth2.public-DraftStyleDefault-listRTL {
margin-right: 4.5em
}
.public-DraftStyleDefault-depth3.public-DraftStyleDefault-listLTR {
margin-left: 6em
}
.public-DraftStyleDefault-depth3.public-DraftStyleDefault-listRTL {
margin-right: 6em
}
.public-DraftStyleDefault-depth4.public-DraftStyleDefault-listLTR {
margin-left: 7.5em
}
.public-DraftStyleDefault-depth4.public-DraftStyleDefault-listRTL {
margin-right: 7.5em
}
.public-DraftStyleDefault-unorderedListItem {
list-style-type: square;
position: relative
}
.public-DraftStyleDefault-unorderedListItem.public-DraftStyleDefault-depth0 {
list-style-type: disc
}
.public-DraftStyleDefault-unorderedListItem.public-DraftStyleDefault-depth1 {
list-style-type: circle
}
.public-DraftStyleDefault-orderedListItem {
list-style-type: none;
position: relative
}
.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-listLTR:before {
left: -36px;
position: absolute;
text-align: right;
width: 30px
}
.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-listRTL:before {
position: absolute;
right: -36px;
text-align: left;
width: 30px
}
.public-DraftStyleDefault-orderedListItem:before {
content: counter(ol0) ". ";
counter-increment: ol0
}
.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth1:before {
content: counter(ol1,lower-alpha) ". ";
counter-increment: ol1
}
.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth2:before {
content: counter(ol2,lower-roman) ". ";
counter-increment: ol2
}
.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth3:before {
content: counter(ol3) ". ";
counter-increment: ol3
}
.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth4:before {
content: counter(ol4,lower-alpha) ". ";
counter-increment: ol4
}
.public-DraftStyleDefault-depth0.public-DraftStyleDefault-reset {
counter-reset: ol0
}
.public-DraftStyleDefault-depth1.public-DraftStyleDefault-reset {
counter-reset: ol1
}
.public-DraftStyleDefault-depth2.public-DraftStyleDefault-reset {
counter-reset: ol2
}
.public-DraftStyleDefault-depth3.public-DraftStyleDefault-reset {
counter-reset: ol3
}
.public-DraftStyleDefault-depth4.public-DraftStyleDefault-reset {
counter-reset: ol4
}
/*# sourceMappingURL=9296efc0128dcaf8.css.map*/

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
.activeWrapper__452c3,.wrapper__452c3 {
height: 100%;
inset-inline-start: 0;
position: absolute;
top: 0;
width: 100%;
z-index: 1002
}
.videoWrapper__452c3,.wrapper__452c3 {
pointer-events: none
}
.videoWrapper__452c3 {
height: 100%;
inset-inline-start: 50%;
position: absolute;
top: 50%;
transform: translate(-50%,-50%);
width: auto;
z-index: 200
}
@media (min-aspect-ratio: 45/32) {
.videoWrapper__452c3 {
height:auto;
width: 100%
}
}
.videoWrapperForHelper__452c3 {
inset-inline-start: 0;
top: 0;
z-index: 200
}
.gadientHighlight__452c3,.videoWrapperForHelper__452c3 {
height: 100%;
pointer-events: none;
position: absolute;
width: 100%
}
.gadientHighlight__452c3 {
background-image: linear-gradient(90deg,var(--premium-tier-2-purple-for-gradients) 0,var(--premium-tier-2-purple-for-gradients-2) 50%,var(--premium-tier-2-pink-for-gradients) 100%)
}
.swipeWrapper__452c3 {
pointer-events: none;
width: 100%
}
.swipe__452c3,.swipeWrapper__452c3 {
height: 100%;
position: absolute
}
.swipe__452c3 {
inset-inline-end: 0;
opacity: .1;
top: 0;
width: auto
}
/*# sourceMappingURL=a06f142ee55db4f5.css.map*/

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long