feat: Initialize the Electron frontend with core UI components and integrate Convex backend services.
This commit is contained in:
1
Frontend/Electron/dist-react/assets/index-0wNLL1lc.css
Normal file
1
Frontend/Electron/dist-react/assets/index-0wNLL1lc.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,10 +5,16 @@
|
||||
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>discord</title>
|
||||
<script type="module" crossorigin src="./assets/index-DXKRzYO-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-D1fin5Al.css">
|
||||
<script type="module" crossorigin src="./assets/index-XO0EnFCR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-0wNLL1lc.css">
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
(function() {
|
||||
var t = localStorage.getItem('discord-theme') || 'theme-dark';
|
||||
document.documentElement.className = t;
|
||||
})();
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
<title>discord</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
(function() {
|
||||
var t = localStorage.getItem('discord-theme') || 'theme-dark';
|
||||
document.documentElement.className = t;
|
||||
})();
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -7,6 +7,7 @@ function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
frame: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
@@ -30,6 +31,22 @@ function createWindow() {
|
||||
app.whenReady().then(() => {
|
||||
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)
|
||||
ipcMain.handle('fetch-metadata', async (event, url) => {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -18,3 +18,9 @@ contextBridge.exposeInMainWorld('cryptoAPI', {
|
||||
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
||||
getScreenSources: () => ipcRenderer.invoke('get-screen-sources'),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('windowControls', {
|
||||
minimize: () => ipcRenderer.send('window-minimize'),
|
||||
maximize: () => ipcRenderer.send('window-maximize'),
|
||||
close: () => ipcRenderer.send('window-close'),
|
||||
});
|
||||
|
||||
62
Frontend/Electron/src/components/Avatar.jsx
Normal file
62
Frontend/Electron/src/components/Avatar.jsx
Normal 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;
|
||||
@@ -39,10 +39,10 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'hsl(240 calc(1*7.143%) 10.98% /1)',
|
||||
backgroundColor: 'var(--bg-primary)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
color: '#dcddde'
|
||||
color: 'var(--text-normal)'
|
||||
}}>
|
||||
{/* Sidebar */}
|
||||
<div style={{
|
||||
@@ -57,7 +57,7 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
color: '#96989d',
|
||||
color: 'var(--text-muted)',
|
||||
marginBottom: '6px',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
@@ -69,8 +69,8 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: activeTab === 'Overview' ? '#393c43' : 'transparent',
|
||||
color: activeTab === 'Overview' ? '#fff' : '#b9bbbe',
|
||||
backgroundColor: activeTab === 'Overview' ? 'var(--background-modifier-selected)' : 'transparent',
|
||||
color: activeTab === 'Overview' ? 'var(--header-primary)' : 'var(--header-secondary)',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '2px',
|
||||
fontSize: '15px'
|
||||
@@ -79,7 +79,7 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
||||
Overview
|
||||
</div>
|
||||
|
||||
<div style={{ height: '1px', backgroundColor: '#3f4147', margin: '8px 0' }} />
|
||||
<div style={{ height: '1px', backgroundColor: 'var(--border-subtle)', margin: '8px 0' }} />
|
||||
|
||||
<div
|
||||
onClick={() => setActiveTab('Delete')}
|
||||
@@ -102,18 +102,18 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, padding: '60px 40px', maxWidth: '740px' }}>
|
||||
<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'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid #b9bbbe',
|
||||
border: '1px solid var(--header-secondary)',
|
||||
borderRadius: '50%',
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
color: '#b9bbbe',
|
||||
color: 'var(--header-secondary)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex', allignItems: 'center', justifyContent: 'center'
|
||||
}}
|
||||
@@ -127,7 +127,7 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
color: '#b9bbbe',
|
||||
color: 'var(--header-secondary)',
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
@@ -141,11 +141,11 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
backgroundColor: '#202225',
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '10px',
|
||||
color: '#dcddde',
|
||||
color: 'var(--text-normal)',
|
||||
fontSize: '16px',
|
||||
outline: 'none'
|
||||
}}
|
||||
@@ -157,7 +157,7 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
backgroundColor: '#3ba55c',
|
||||
color: 'white',
|
||||
color: 'var(--header-primary)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '10px 24px',
|
||||
@@ -173,16 +173,16 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
||||
)}
|
||||
|
||||
{activeTab === 'Delete' && (
|
||||
<div style={{ backgroundColor: '#202225', padding: '16px', borderRadius: '8px', border: '1px solid #ed4245' }}>
|
||||
<h3 style={{ color: 'white', marginTop: 0 }}>Are you sure?</h3>
|
||||
<p style={{ color: '#b9bbbe' }}>
|
||||
<div style={{ backgroundColor: 'var(--bg-tertiary)', padding: '16px', borderRadius: '8px', border: '1px solid #ed4245' }}>
|
||||
<h3 style={{ color: 'var(--header-primary)', marginTop: 0 }}>Are you sure?</h3>
|
||||
<p style={{ color: 'var(--header-secondary)' }}>
|
||||
Deleting <b>#{channel.name}</b> cannot be undone. All messages and keys will be lost forever.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
style={{
|
||||
backgroundColor: '#ed4245',
|
||||
color: 'white',
|
||||
color: 'var(--header-primary)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '10px 24px',
|
||||
|
||||
@@ -7,8 +7,8 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import {
|
||||
GifIcon,
|
||||
EmojieIcon,
|
||||
StickerIcon,
|
||||
EmojieIcon,
|
||||
EmojiesColored,
|
||||
EmojiesGreyscale,
|
||||
EditIcon,
|
||||
@@ -26,6 +26,11 @@ const fireIcon = getEmojiUrl('nature', 'fire');
|
||||
const heartIcon = getEmojiUrl('symbols', 'heart');
|
||||
const thumbsupIcon = getEmojiUrl('people', 'thumbsup');
|
||||
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 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) => {
|
||||
if (!previous) return true;
|
||||
return current.getDate() !== previous.getDate()
|
||||
@@ -234,7 +253,7 @@ const Attachment = ({ metadata, onLoad, onImageClick }) => {
|
||||
return () => { isMounted = false; };
|
||||
}, [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 (metadata.mimeType.startsWith('image/')) {
|
||||
@@ -250,12 +269,12 @@ const Attachment = ({ metadata, onLoad, onImageClick }) => {
|
||||
);
|
||||
}
|
||||
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>
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
<div style={{ color: '#00b0f4', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{metadata.filename}</div>
|
||||
<div style={{ color: '#b9bbbe', fontSize: '12px' }}>{(metadata.size / 1024).toFixed(1)} KB</div>
|
||||
<a href={url} download={metadata.filename} style={{ color: '#b9bbbe', fontSize: '12px', textDecoration: 'underline' }}>Download</a>
|
||||
<div style={{ color: 'var(--text-link)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{metadata.filename}</div>
|
||||
<div style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>{(metadata.size / 1024).toFixed(1)} KB</div>
|
||||
<a href={url} download={metadata.filename} style={{ color: 'var(--header-secondary)', fontSize: '12px', textDecoration: 'underline' }}>Download</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -296,14 +315,14 @@ const PendingFilePreview = ({ file, onRemove }) => {
|
||||
previewContent = (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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}
|
||||
<div style={{ position: 'absolute', top: '4px', right: '4px', display: 'flex', gap: '4px', padding: '4px' }}>
|
||||
<ActionButton icon={SpoilerIcon} onClick={() => {}} />
|
||||
@@ -312,7 +331,7 @@ const PendingFilePreview = ({ file, onRemove }) => {
|
||||
</div>
|
||||
</div>
|
||||
{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>
|
||||
);
|
||||
@@ -339,27 +358,45 @@ const EmojiButton = ({ onClick, active }) => {
|
||||
return `-${col * 24}px -${row * 24}px`;
|
||||
};
|
||||
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">
|
||||
<div style={{ width: '24px', height: '24px', backgroundImage: `url(${(hovered || active) ? EmojiesColored : EmojiesGreyscale})`, backgroundPosition: bgPos, backgroundSize: '480px 96px', backgroundRepeat: 'no-repeat' }} />
|
||||
</div>
|
||||
<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>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
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' }}>
|
||||
<IconButton onClick={() => onAddReaction('thumbsup')} title="Add Reaction" emoji={<ColoredIcon src={thumbsupIcon} size="20px" />} />
|
||||
<IconButton onClick={() => onAddReaction('heart')} title="Add Reaction" emoji={<ColoredIcon src={heartIcon} size="20px" />} />
|
||||
<IconButton onClick={() => onAddReaction('fire')} title="Add Reaction" emoji={<ColoredIcon src={fireIcon} size="20px" />} />
|
||||
<div className="message-toolbar">
|
||||
<Tooltip text="Thumbs Up" position="top">
|
||||
<IconButton onClick={() => onAddReaction('thumbsup')} emoji={<ColoredIcon src={thumbsupIcon} 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>
|
||||
<IconButton onClick={() => onAddReaction(null)} title="Add Reaction" emoji={<ColoredIcon src={EmojieIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
||||
{isOwner && <IconButton onClick={onEdit} title="Edit" emoji={<ColoredIcon src={EditIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />}
|
||||
<IconButton onClick={onReply} title="Reply" emoji={<ColoredIcon src={ReplyIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
||||
<IconButton onClick={onMore} title="More" emoji={<ColoredIcon src={MoreIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
||||
<Tooltip text="Add Reaction" position="top">
|
||||
<IconButton onClick={() => onAddReaction(null)} emoji={<ColoredIcon src={EmojieIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
||||
</Tooltip>
|
||||
{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>
|
||||
);
|
||||
|
||||
const IconButton = ({ onClick, title, 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'}>
|
||||
const IconButton = ({ onClick, emoji }) => (
|
||||
<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}
|
||||
</div>
|
||||
);
|
||||
@@ -380,20 +417,20 @@ const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner }) => {
|
||||
}, [x, y]);
|
||||
|
||||
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>
|
||||
<div style={{ marginLeft: '12px' }}><ColoredIcon src={iconSrc} color={iconColor} size="18px" /></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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')} />
|
||||
{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')} />
|
||||
<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')} />
|
||||
<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')} />}
|
||||
</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 [input, setInput] = useState('');
|
||||
const [zoomedImage, setZoomedImage] = useState(null);
|
||||
@@ -452,6 +499,12 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
||||
const [hoveredMessageId, setHoveredMessageId] = useState(null);
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
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 messagesContainerRef = useRef(null);
|
||||
@@ -470,6 +523,8 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
||||
|
||||
const convex = useConvex();
|
||||
|
||||
const members = useQuery(api.members.getChannelMembers, channelId ? { channelId } : "skip") || [];
|
||||
|
||||
const { results: rawMessages, status, loadMore } = usePaginatedQuery(
|
||||
api.messages.list,
|
||||
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 editMessageMutation = useMutation(api.messages.edit);
|
||||
const pinMessageMutation = useMutation(api.messages.pin);
|
||||
const deleteMessageMutation = useMutation(api.messages.remove);
|
||||
const addReaction = useMutation(api.reactions.add);
|
||||
const removeReaction = useMutation(api.reactions.remove);
|
||||
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) => {
|
||||
if (!msg.signature || !msg.public_signing_key) return false;
|
||||
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;
|
||||
|
||||
const processMessages = async () => {
|
||||
// Decrypt only messages not already in cache
|
||||
const needsDecryption = rawMessages.filter(msg => !cache.has(msg.id));
|
||||
const needsDecryption = rawMessages.filter(msg => {
|
||||
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) {
|
||||
await Promise.all(needsDecryption.map(async (msg) => {
|
||||
const content = await decryptMessage(msg);
|
||||
const isVerified = await verifyMessage(msg);
|
||||
let decryptedReply = null;
|
||||
if (msg.replyToContent && msg.replyToNonce) {
|
||||
decryptedReply = await decryptReplyContent(msg.replyToContent, msg.replyToNonce);
|
||||
}
|
||||
if (!cancelled) {
|
||||
cache.set(msg.id, { content, isVerified });
|
||||
cache.set(msg.id, { content, isVerified, decryptedReply });
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// Build full chronological array (rawMessages is newest-first, reverse for display)
|
||||
const processed = [...rawMessages].reverse().map(msg => {
|
||||
const cached = cache.get(msg.id);
|
||||
return {
|
||||
...msg,
|
||||
content: cached?.content ?? '[Decrypting...]',
|
||||
isVerified: cached?.isVerified ?? false,
|
||||
decryptedReply: cached?.decryptedReply ?? null,
|
||||
};
|
||||
});
|
||||
setDecryptedMessages(processed);
|
||||
@@ -555,15 +636,19 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
||||
return () => { cancelled = true; };
|
||||
}, [rawMessages, channelKey]);
|
||||
|
||||
// Clear decryption cache and reset scroll state on channel/key change
|
||||
useEffect(() => {
|
||||
decryptionCacheRef.current.clear();
|
||||
setDecryptedMessages([]);
|
||||
isInitialLoadRef.current = true;
|
||||
prevResultsLengthRef.current = 0;
|
||||
setReplyingTo(null);
|
||||
setEditingMessage(null);
|
||||
setMentionQuery(null);
|
||||
onTogglePinned();
|
||||
}, [channelId, channelKey]);
|
||||
|
||||
const typingUsers = typingData.filter(t => t.username !== username);
|
||||
const filteredMentionMembers = mentionQuery !== null ? filterMembersForMention(members, mentionQuery) : [];
|
||||
|
||||
const scrollToBottom = useCallback((force = false) => {
|
||||
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(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container || decryptedMessages.length === 0) return;
|
||||
|
||||
// Initial load — instant scroll to bottom (no animation)
|
||||
if (isInitialLoadRef.current) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
isInitialLoadRef.current = false;
|
||||
@@ -589,7 +672,6 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
||||
return;
|
||||
}
|
||||
|
||||
// Load more (older messages prepended) — preserve scroll position
|
||||
if (isLoadingMoreRef.current) {
|
||||
const newScrollHeight = container.scrollHeight;
|
||||
const heightDifference = newScrollHeight - prevScrollHeightRef.current;
|
||||
@@ -599,7 +681,6 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
||||
return;
|
||||
}
|
||||
|
||||
// User sent a message — force scroll to bottom
|
||||
if (userSentMessageRef.current) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
userSentMessageRef.current = false;
|
||||
@@ -607,7 +688,6 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
||||
return;
|
||||
}
|
||||
|
||||
// Real-time new message — auto-scroll if near bottom
|
||||
const currentLen = rawMessages?.length || 0;
|
||||
const prevLen = prevResultsLengthRef.current;
|
||||
if (currentLen > prevLen && (currentLen - prevLen) <= 5) {
|
||||
@@ -620,7 +700,6 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
||||
prevResultsLengthRef.current = currentLen;
|
||||
}, [decryptedMessages, rawMessages?.length]);
|
||||
|
||||
// IntersectionObserver to trigger loadMore when scrolling near the top
|
||||
useEffect(() => {
|
||||
const sentinel = topSentinelRef.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 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 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); };
|
||||
@@ -726,7 +846,7 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
||||
await sendMessage(JSON.stringify(metadata));
|
||||
};
|
||||
|
||||
const sendMessage = async (contentString) => {
|
||||
const sendMessage = async (contentString, replyToId) => {
|
||||
try {
|
||||
if (!channelKey) { alert("Cannot send: Missing Encryption Key"); return; }
|
||||
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');
|
||||
if (!senderId || !signingKey) return;
|
||||
|
||||
await sendMessageMutation({
|
||||
const args = {
|
||||
channelId,
|
||||
senderId,
|
||||
ciphertext,
|
||||
nonce: iv,
|
||||
signature: await window.cryptoAPI.signMessage(signingKey, ciphertext),
|
||||
keyVersion: 1
|
||||
});
|
||||
};
|
||||
if (replyToId) args.replyTo = replyToId;
|
||||
|
||||
await sendMessageMutation(args);
|
||||
} catch (err) {
|
||||
console.error('Send error:', err);
|
||||
}
|
||||
@@ -772,15 +895,18 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
||||
|
||||
setUploading(true);
|
||||
userSentMessageRef.current = true;
|
||||
const replyId = replyingTo?.messageId;
|
||||
try {
|
||||
for (const file of pendingFiles) await uploadAndSendFile(file);
|
||||
setPendingFiles([]);
|
||||
if (messageContent) {
|
||||
await sendMessage(messageContent);
|
||||
await sendMessage(messageContent, replyId);
|
||||
if (inputDivRef.current) inputDivRef.current.innerHTML = '';
|
||||
setInput(''); setHasImages(false);
|
||||
clearTypingState();
|
||||
}
|
||||
setReplyingTo(null);
|
||||
setMentionQuery(null);
|
||||
} catch (err) {
|
||||
console.error("Error sending message/files:", err);
|
||||
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) => {
|
||||
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 === 'Escape' && replyingTo) { setReplyingTo(null); return; }
|
||||
if (e.key === 'Backspace' && inputDivRef.current) {
|
||||
const sel = window.getSelection();
|
||||
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) => {
|
||||
if (!currentUserId) return;
|
||||
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 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);
|
||||
if (attachmentMetadata) {
|
||||
return <Attachment metadata={attachmentMetadata} onLoad={scrollToBottom} onImageClick={setZoomedImage} />;
|
||||
@@ -854,18 +1061,30 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}>
|
||||
{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' }}>
|
||||
<ColoredIcon src={getReactionIcon(emojiName)} size="16px" color={data.me ? null : '#b9bbbe'} />
|
||||
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : '#b9bbbe', fontWeight: 600 }}>{data.count}</span>
|
||||
<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 : 'var(--header-secondary)'} />
|
||||
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : 'var(--header-secondary)', fontWeight: 600 }}>{data.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const isDM = channelType === 'dm';
|
||||
const placeholderText = isDM ? `Message @${channelName || 'user'}` : `Message #${channelName || channelId}`;
|
||||
|
||||
return (
|
||||
<div className="chat-area" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} style={{ position: 'relative' }}>
|
||||
{isDragging && <DragOverlay />}
|
||||
|
||||
<PinnedMessagesPanel
|
||||
channelId={channelId}
|
||||
visible={showPinned}
|
||||
onClose={onTogglePinned}
|
||||
channelKey={channelKey}
|
||||
onJumpToMessage={scrollToMessage}
|
||||
/>
|
||||
|
||||
<div className="messages-list" ref={messagesContainerRef}>
|
||||
<div className="messages-content-wrapper">
|
||||
<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 && (
|
||||
<div className="channel-beginning">
|
||||
<div className="channel-beginning-icon">#</div>
|
||||
<h1 className="channel-beginning-title">Welcome to #{channelName}</h1>
|
||||
<p className="channel-beginning-subtitle">This is the start of the #{channelName} channel.</p>
|
||||
<div className="channel-beginning-icon">{isDM ? '@' : '#'}</div>
|
||||
<h1 className="channel-beginning-title">
|
||||
{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>
|
||||
)}
|
||||
{status === 'LoadingFirstPage' && (
|
||||
@@ -892,18 +1118,39 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
||||
const isMentioned = msg.content && msg.content.includes(`@${username}`);
|
||||
const userColor = getUserColor(msg.username || 'Unknown');
|
||||
const isOwner = msg.username === username;
|
||||
const isEditing = editingMessage?.id === msg.id;
|
||||
|
||||
const prevMsg = idx > 0 ? decryptedMessages[idx - 1] : null;
|
||||
const isGrouped = prevMsg
|
||||
&& prevMsg.username === msg.username
|
||||
&& !isNewDay(currentDate, previousDate)
|
||||
&& (currentDate - new Date(prevMsg.created_at)) < 60000;
|
||||
&& (currentDate - new Date(prevMsg.created_at)) < 60000
|
||||
&& !msg.replyToId;
|
||||
|
||||
return (
|
||||
<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>}
|
||||
<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' }} />}
|
||||
|
||||
{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 ? (
|
||||
<div className="message-avatar-wrapper grouped-timestamp-wrapper">
|
||||
<span className="grouped-timestamp">
|
||||
@@ -912,29 +1159,56 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
||||
</div>
|
||||
) : (
|
||||
<div className="message-avatar-wrapper">
|
||||
<div className="message-avatar" style={{ backgroundColor: userColor }}>
|
||||
{(msg.username || '?').substring(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<Avatar
|
||||
username={msg.username}
|
||||
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 className="message-body">
|
||||
{!isGrouped && (
|
||||
<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>}
|
||||
<span className="timestamp">{currentDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="message-content">
|
||||
{renderMessageContent(msg)}
|
||||
{renderReactions(msg)}
|
||||
</div>
|
||||
{hoveredMessageId === msg.id && (
|
||||
{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">
|
||||
{renderMessageContent(msg)}
|
||||
{msg.editedAt && <span className="edited-indicator">(edited)</span>}
|
||||
{renderReactions(msg)}
|
||||
</div>
|
||||
)}
|
||||
{hoveredMessageId === msg.id && !isEditing && (
|
||||
<MessageToolbar isOwner={isOwner}
|
||||
onAddReaction={(emoji) => { addReaction({ messageId: msg.id, userId: currentUserId, emoji: emoji || 'heart' }); }}
|
||||
onEdit={() => console.log('Edit', msg.id)}
|
||||
onReply={() => console.log('Reply', msg.id)}
|
||||
onEdit={() => { setEditingMessage({ id: msg.id, content: msg.content }); setEditInput(msg.content); }}
|
||||
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 }); }}
|
||||
/>
|
||||
)}
|
||||
@@ -947,17 +1221,36 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
||||
<div ref={messagesEndRef} />
|
||||
</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' }}>
|
||||
{mentionQuery !== null && filteredMentionMembers.length > 0 && (
|
||||
<MentionMenu
|
||||
members={filteredMentionMembers}
|
||||
selectedIndex={mentionIndex}
|
||||
onSelect={insertMention}
|
||||
onHover={setMentionIndex}
|
||||
/>
|
||||
)}
|
||||
{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' }}>
|
||||
<ColoredIcon src={TypingIcon} size="24px" color="#dbdee1" />
|
||||
<span>{typingUsers.map(t => t.username).join(', ')} is typing...</span>
|
||||
</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)}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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))} />)}
|
||||
</div>
|
||||
)}
|
||||
@@ -971,11 +1264,20 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
||||
onMouseUp={saveSelection}
|
||||
onKeyUp={saveSelection}
|
||||
onInput={(e) => {
|
||||
setInput(e.currentTarget.textContent);
|
||||
const textContent = e.currentTarget.textContent;
|
||||
setInput(textContent);
|
||||
setHasImages(e.currentTarget.querySelectorAll('img').length > 0);
|
||||
const text = e.currentTarget.innerText;
|
||||
setIsMultiline(text.includes('\n') || e.currentTarget.scrollHeight > 50);
|
||||
|
||||
// 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;
|
||||
setIsMultiline(text.includes('\n') || e.currentTarget.scrollHeight > 50);
|
||||
}
|
||||
checkTypedEmoji();
|
||||
checkMentionTrigger();
|
||||
const now = Date.now();
|
||||
if (now - lastTypingEmitRef.current > 2000 && currentUserId && channelId) {
|
||||
startTypingMutation({ channelId, userId: currentUserId, username }).catch(() => {});
|
||||
@@ -988,17 +1290,23 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
||||
}, 3000);
|
||||
}}
|
||||
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' }}>
|
||||
<button type="button" className="chat-input-icon-btn" title="GIF" onClick={(e) => { e.stopPropagation(); togglePicker('GIFs'); }}>
|
||||
<ColoredIcon src={GifIcon} color={pickerTab === 'GIFs' ? '#5865f2' : ICON_COLOR_DEFAULT} size="24px" />
|
||||
</button>
|
||||
<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" />
|
||||
</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 && (
|
||||
<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')} />
|
||||
</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()} />
|
||||
</div>
|
||||
)}
|
||||
{profilePopup && (
|
||||
<UserProfilePopup
|
||||
userId={profilePopup.userId}
|
||||
username={profilePopup.username}
|
||||
avatarUrl={profilePopup.avatarUrl}
|
||||
status="online"
|
||||
position={profilePopup.position}
|
||||
onClose={() => setProfilePopup(null)}
|
||||
onSendMessage={onOpenDM}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
74
Frontend/Electron/src/components/ChatHeader.jsx
Normal file
74
Frontend/Electron/src/components/ChatHeader.jsx
Normal 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;
|
||||
@@ -1,29 +1,48 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useConvex } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import Tooltip from './Tooltip';
|
||||
import Avatar from './Avatar';
|
||||
|
||||
const STATUS_COLORS = {
|
||||
online: '#3ba55c',
|
||||
idle: '#faa61a',
|
||||
dnd: '#ed4245',
|
||||
invisible: '#747f8d',
|
||||
offline: '#747f8d',
|
||||
};
|
||||
|
||||
const STATUS_LABELS = {
|
||||
online: 'Online',
|
||||
idle: 'Idle',
|
||||
dnd: 'Do Not Disturb',
|
||||
invisible: 'Offline',
|
||||
offline: 'Offline',
|
||||
};
|
||||
|
||||
const getUserColor = (username) => {
|
||||
if (!username) return '#5865F2';
|
||||
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < username.length; i++) {
|
||||
hash = username.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
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 getUserColor = (username) => {
|
||||
if (!username) return '#5865F2';
|
||||
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < username.length; i++) {
|
||||
hash = username.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
};
|
||||
|
||||
const handleOpenUserPicker = async () => {
|
||||
setShowUserPicker(true);
|
||||
setSearchQuery('');
|
||||
// Fetch all users via Convex query
|
||||
try {
|
||||
const data = await convex.query(api.auth.getPublicKeys, {});
|
||||
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(() => {
|
||||
if (showUserPicker && searchRef.current) {
|
||||
searchRef.current.focus();
|
||||
@@ -43,57 +75,69 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
|
||||
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 (
|
||||
<div style={{ padding: '8px', flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Search / New DM Button */}
|
||||
<button
|
||||
onClick={handleOpenUserPicker}
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
backgroundColor: '#202225',
|
||||
color: '#96989d',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
fontSize: '13px',
|
||||
marginBottom: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Find or start a conversation
|
||||
</button>
|
||||
{/* Search Input */}
|
||||
<div className="dm-search-wrapper">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="dm-search-input"
|
||||
placeholder="Find or start a conversation"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onFocus={handleSearchFocus}
|
||||
onBlur={() => {
|
||||
setTimeout(() => setSearchFocused(false), 200);
|
||||
}}
|
||||
/>
|
||||
{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);
|
||||
}}
|
||||
>
|
||||
<Avatar username={user.username} size={24} style={{ marginRight: '8px' }} />
|
||||
<span>{user.username}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Picker Modal/Dropdown */}
|
||||
{/* User Picker Modal */}
|
||||
{showUserPicker && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)', zIndex: 100,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
}}
|
||||
onClick={() => setShowUserPicker(false)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#36393f',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
width: '400px',
|
||||
maxHeight: '500px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
backgroundColor: 'var(--bg-primary)', borderRadius: '8px', padding: '16px',
|
||||
width: '400px', maxHeight: '500px', display: 'flex', flexDirection: 'column'
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<h3 style={{ color: '#fff', 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>
|
||||
<h3 style={{ color: 'var(--header-primary)', margin: '0 0 4px 0', fontSize: '16px' }}>Select a User</h3>
|
||||
<p style={{ color: 'var(--header-secondary)', fontSize: '12px', margin: '0 0 12px 0' }}>Start a new direct message conversation.</p>
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
@@ -101,52 +145,24 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
backgroundColor: '#202225',
|
||||
border: '1px solid #040405',
|
||||
borderRadius: '4px',
|
||||
color: '#dcddde',
|
||||
padding: '8px 12px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
marginBottom: '8px',
|
||||
boxSizing: 'border-box'
|
||||
width: '100%', backgroundColor: 'var(--bg-tertiary)', border: '1px solid var(--border-subtle)',
|
||||
borderRadius: '4px', color: 'var(--text-normal)', padding: '8px 12px', fontSize: '14px',
|
||||
outline: 'none', marginBottom: '8px', boxSizing: 'border-box'
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, overflowY: 'auto', maxHeight: '300px' }}>
|
||||
{filteredUsers.map(user => (
|
||||
<div
|
||||
key={user.id}
|
||||
onClick={() => {
|
||||
setShowUserPicker(false);
|
||||
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'}
|
||||
className="dm-picker-user"
|
||||
onClick={() => { setShowUserPicker(false); onOpenDM(user.id, user.username); }}
|
||||
>
|
||||
<div style={{
|
||||
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>
|
||||
<Avatar username={user.username} size={32} style={{ marginRight: '12px' }} />
|
||||
<span style={{ fontWeight: '500' }}>{user.username}</span>
|
||||
</div>
|
||||
))}
|
||||
{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.
|
||||
</div>
|
||||
)}
|
||||
@@ -157,19 +173,8 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
|
||||
|
||||
{/* Friends Button */}
|
||||
<div
|
||||
style={{
|
||||
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'
|
||||
}}
|
||||
className={`dm-friends-btn ${!activeDMChannel ? 'active' : ''}`}
|
||||
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' }}>
|
||||
<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 */}
|
||||
<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
|
||||
style={{ cursor: 'pointer', fontSize: '16px' }}
|
||||
onClick={handleOpenUserPicker}
|
||||
title="New DM"
|
||||
>+</span>
|
||||
<Tooltip text="New DM" position="top">
|
||||
<span
|
||||
style={{ cursor: 'pointer', fontSize: '16px' }}
|
||||
onClick={handleOpenUserPicker}
|
||||
>+</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* DM Channel List */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{(dmChannels || []).map(dm => {
|
||||
const isActive = activeDMChannel?.channel_id === dm.channel_id;
|
||||
const status = dm.other_user_status || 'online';
|
||||
return (
|
||||
<div
|
||||
key={dm.channel_id}
|
||||
className={`dm-item ${isActive ? 'dm-item-active' : ''}`}
|
||||
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={{
|
||||
width: '32px',
|
||||
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 style={{ display: 'flex', alignItems: 'center', flex: 1, minWidth: 0 }}>
|
||||
<div style={{ position: 'relative', marginRight: '12px', flexShrink: 0 }}>
|
||||
<Avatar username={dm.other_username} size={32} />
|
||||
<div style={{
|
||||
position: 'absolute', bottom: -2, right: -2,
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
backgroundColor: STATUS_COLORS[status] || STATUS_COLORS.online,
|
||||
border: '2px solid var(--bg-secondary)'
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ overflow: 'hidden', flex: 1 }}>
|
||||
<div style={{ color: isActive ? 'var(--header-primary)' : 'var(--text-normal)', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', fontWeight: '500' }}>
|
||||
{dm.other_username}
|
||||
</div>
|
||||
<div className="dm-item-status">
|
||||
{STATUS_LABELS[status] || 'Online'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
position: 'absolute', bottom: -2, right: -2,
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
backgroundColor: '#3ba55c',
|
||||
border: '2px solid #2f3136'
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
<div style={{ color: isActive ? '#fff' : '#dcddde', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', fontWeight: '500' }}>
|
||||
{dm.other_username}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
{(!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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import Avatar from './Avatar';
|
||||
|
||||
const FriendsView = ({ onOpenDM }) => {
|
||||
const [activeTab, setActiveTab] = useState('Online');
|
||||
const [addFriendSearch, setAddFriendSearch] = useState('');
|
||||
|
||||
const myId = localStorage.getItem('userId');
|
||||
|
||||
// Reactive query for all users' public keys
|
||||
const allUsers = useQuery(api.auth.getPublicKeys) || [];
|
||||
const users = allUsers.filter(u => u.id !== myId);
|
||||
|
||||
@@ -21,14 +22,26 @@ const FriendsView = ({ onOpenDM }) => {
|
||||
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 (
|
||||
<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 */}
|
||||
<div style={{
|
||||
height: '48px',
|
||||
borderBottom: '1px solid #26272d',
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 16px',
|
||||
@@ -36,22 +49,23 @@ const FriendsView = ({ onOpenDM }) => {
|
||||
fontWeight: 'bold',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginRight: '16px', paddingRight: '16px', borderRight: '1px solid #4a4c52' }}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" style={{ marginRight: 8, color: '#72767d' }}>
|
||||
<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: '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="M7 13C7 11.8954 7.89543 11 9 11H15C16.1046 11 17 11.8954 17 13V15H7V13Z" fill="currentColor"/>
|
||||
</svg>
|
||||
Friends
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '16px' }}>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
{['Online', 'All'].map(tab => (
|
||||
<div
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className="friends-tab"
|
||||
style={{
|
||||
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',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px'
|
||||
@@ -60,18 +74,55 @@ const FriendsView = ({ onOpenDM }) => {
|
||||
{tab}
|
||||
</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>
|
||||
|
||||
{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 */}
|
||||
<div style={{ padding: '16px 20px 8px' }}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
color: '#b9bbbe',
|
||||
color: 'var(--header-secondary)',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
{activeTab} — {filteredUsers.length}
|
||||
{activeTab === 'Add Friend' ? 'USERS' : activeTab} — {filteredUsers.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,56 +131,43 @@ const FriendsView = ({ onOpenDM }) => {
|
||||
{filteredUsers.map(user => (
|
||||
<div
|
||||
key={user.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 0',
|
||||
borderTop: '1px solid #3f4147',
|
||||
cursor: 'pointer',
|
||||
':hover': { backgroundColor: 'rgba(255,255,255,0.02)' }
|
||||
}}
|
||||
className="friend-item"
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ position: 'relative', marginRight: '12px' }}>
|
||||
<div style={{
|
||||
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>
|
||||
<Avatar username={user.username} avatarUrl={user.avatarUrl} size={32} />
|
||||
<div style={{
|
||||
position: 'absolute', bottom: -2, right: -2,
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
backgroundColor: '#3ba55c',
|
||||
border: '2px solid #36393f'
|
||||
backgroundColor: STATUS_COLORS[user.status] || STATUS_COLORS.online,
|
||||
border: '2px solid var(--bg-primary)'
|
||||
}} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#fff', fontWeight: '600' }}>
|
||||
<div style={{ color: 'var(--header-primary)', fontWeight: '600' }}>
|
||||
{user.username ?? 'Unknown'}
|
||||
</div>
|
||||
<div style={{ color: '#b9bbbe', fontSize: '12px' }}>
|
||||
Online
|
||||
<div style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>
|
||||
{user.status === 'dnd' ? 'Do Not Disturb' : (user.status || 'Online').charAt(0).toUpperCase() + (user.status || 'online').slice(1)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<div
|
||||
style={{ padding: 8, backgroundColor: '#2f3136', borderRadius: '50%', cursor: 'pointer' }}
|
||||
className="friend-action-btn"
|
||||
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 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>
|
||||
|
||||
@@ -9,7 +9,7 @@ const EmojiItem = ({ emoji, onSelect }) => (
|
||||
onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })}
|
||||
title={`:${emoji.name}:`}
|
||||
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'}
|
||||
>
|
||||
<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)}
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -63,7 +63,7 @@ const GifContent = ({ search, results, categories, onSelect, onCategoryClick })
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: '#202225'
|
||||
backgroundColor: 'var(--bg-tertiary)'
|
||||
}}
|
||||
>
|
||||
<video
|
||||
@@ -123,7 +123,7 @@ const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory })
|
||||
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'}
|
||||
>
|
||||
<svg
|
||||
@@ -132,7 +132,7 @@ const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory })
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#b9bbbe"
|
||||
stroke="var(--header-secondary)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -145,7 +145,7 @@ const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory })
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
<h3 style={{
|
||||
color: '#b9bbbe',
|
||||
color: 'var(--header-secondary)',
|
||||
fontSize: '12px',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 700,
|
||||
@@ -235,7 +235,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
right: '0',
|
||||
width: '400px',
|
||||
height: '450px',
|
||||
backgroundColor: '#2f3136',
|
||||
backgroundColor: 'var(--embed-background)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 8px 16px rgba(0,0,0,0.24)',
|
||||
display: 'flex',
|
||||
@@ -246,7 +246,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 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 => (
|
||||
<button
|
||||
key={tab}
|
||||
@@ -254,12 +254,12 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: activeTab === tab ? '#fff' : '#b9bbbe',
|
||||
color: activeTab === tab ? 'var(--header-primary)' : 'var(--header-secondary)',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
paddingBottom: '4px',
|
||||
borderBottom: activeTab === tab ? '2px solid #5865f2' : '2px solid transparent'
|
||||
borderBottom: activeTab === tab ? '2px solid var(--brand-experiment)' : '2px solid transparent'
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
@@ -270,7 +270,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
{/* Search Bar */}
|
||||
<div style={{ padding: '8px 16px' }}>
|
||||
<div style={{
|
||||
backgroundColor: '#202225',
|
||||
backgroundColor: 'var(--bg-tertiary)',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -292,14 +292,14 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#dcddde',
|
||||
color: 'var(--text-normal)',
|
||||
padding: '8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none'
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
@@ -310,7 +310,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
||||
{/* Content Area */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 16px 16px 16px' }}>
|
||||
{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' ? (
|
||||
<GifContent search={search} results={results} categories={categories} onSelect={onSelect} onCategoryClick={setSearch} />
|
||||
) : (
|
||||
|
||||
130
Frontend/Electron/src/components/MembersList.jsx
Normal file
130
Frontend/Electron/src/components/MembersList.jsx
Normal 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;
|
||||
43
Frontend/Electron/src/components/MentionMenu.jsx
Normal file
43
Frontend/Electron/src/components/MentionMenu.jsx
Normal 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;
|
||||
89
Frontend/Electron/src/components/PinnedMessagesPanel.jsx
Normal file
89
Frontend/Electron/src/components/PinnedMessagesPanel.jsx
Normal 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}>×</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;
|
||||
@@ -67,7 +67,7 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'flex-end', padding: '60px 6px 60px 20px'
|
||||
}}>
|
||||
<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
|
||||
</div>
|
||||
{['Overview', 'Roles', 'Members'].map(tab => (
|
||||
@@ -76,8 +76,8 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
padding: '6px 10px', borderRadius: '4px',
|
||||
backgroundColor: activeTab === tab ? '#393c43' : 'transparent',
|
||||
color: activeTab === tab ? '#fff' : '#b9bbbe',
|
||||
backgroundColor: activeTab === tab ? 'var(--background-modifier-selected)' : 'transparent',
|
||||
color: activeTab === tab ? 'var(--header-primary)' : 'var(--header-secondary)',
|
||||
cursor: 'pointer', marginBottom: '2px', fontSize: '15px'
|
||||
}}
|
||||
>
|
||||
@@ -90,16 +90,16 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
|
||||
const canManageRoles = myPermissions.manage_roles;
|
||||
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 renderRolesTab = () => (
|
||||
<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' }}>
|
||||
<h3 style={{ color: '#b9bbbe', fontSize: '12px' }}>ROLES</h3>
|
||||
<h3 style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>ROLES</h3>
|
||||
{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>
|
||||
{editableRoles.map(r => (
|
||||
@@ -108,7 +108,7 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
onClick={() => setSelectedRole(r)}
|
||||
style={{
|
||||
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',
|
||||
display: 'flex', alignItems: 'center'
|
||||
}}
|
||||
@@ -121,14 +121,14 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
|
||||
{selectedRole ? (
|
||||
<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>
|
||||
<input
|
||||
value={selectedRole.name}
|
||||
onChange={(e) => handleUpdateRole(selectedRole._id, { name: e.target.value })}
|
||||
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>
|
||||
@@ -142,8 +142,8 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
|
||||
<label style={labelStyle}>PERMISSIONS</label>
|
||||
{['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' }}>
|
||||
<span style={{ color: 'white', textTransform: 'capitalize' }}>{perm.replace('_', ' ')}</span>
|
||||
<div key={perm} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10, paddingBottom: 10, borderBottom: '1px solid var(--border-subtle)' }}>
|
||||
<span style={{ color: 'var(--header-primary)', textTransform: 'capitalize' }}>{perm.replace('_', ' ')}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRole.permissions?.[perm] || false}
|
||||
@@ -165,24 +165,24 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: '#b9bbbe' }}>Select a role to edit</div>
|
||||
<div style={{ color: 'var(--header-secondary)' }}>Select a role to edit</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderMembersTab = () => (
|
||||
<div>
|
||||
<h2 style={{ color: 'white' }}>Members</h2>
|
||||
<h2 style={{ color: 'var(--header-primary)' }}>Members</h2>
|
||||
{members.map(m => (
|
||||
<div key={m.id} style={{ display: 'flex', alignItems: 'center', padding: '10px', borderBottom: '1px solid #3f4147' }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#5865F2', marginRight: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white' }}>
|
||||
<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: 'var(--header-primary)' }}>
|
||||
{m.username[0].toUpperCase()}
|
||||
</div>
|
||||
<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 }}>
|
||||
{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}
|
||||
</span>
|
||||
))}
|
||||
@@ -217,17 +217,17 @@ const ServerSettingsModal = ({ onClose }) => {
|
||||
switch (activeTab) {
|
||||
case 'Roles': return renderRolesTab();
|
||||
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 (
|
||||
<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()}
|
||||
<div style={{ flex: 1, padding: '60px 40px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<h2 style={{ color: 'white', 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>
|
||||
<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: 'var(--header-secondary)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>✕</button>
|
||||
</div>
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useConvex } from 'convex/react';
|
||||
import { useConvex, useMutation } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import Tooltip from './Tooltip';
|
||||
import { useVoice } from '../contexts/VoiceContext';
|
||||
import ChannelSettingsModal from './ChannelSettingsModal';
|
||||
import ServerSettingsModal from './ServerSettingsModal';
|
||||
import ScreenShareModal from './ScreenShareModal';
|
||||
import DMList from './DMList';
|
||||
import Avatar from './Avatar';
|
||||
import ThemeSelector from './ThemeSelector';
|
||||
import { Track } from 'livekit-client';
|
||||
import muteIcon from '../assets/icons/mute.svg';
|
||||
import mutedIcon from '../assets/icons/muted.svg';
|
||||
@@ -74,47 +77,80 @@ const ColoredIcon = ({ src, color, size = '20px' }) => (
|
||||
</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 [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 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 (
|
||||
<div style={{
|
||||
height: '64px',
|
||||
margin: '0 8px 8px 8px',
|
||||
borderRadius: connectionState === 'connected' ? '0px 0px 8px 8px' : '8px',
|
||||
backgroundColor: '#292b2f',
|
||||
backgroundColor: 'var(--panel-bg)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 8px',
|
||||
flexShrink: 0
|
||||
flexShrink: 0,
|
||||
position: 'relative'
|
||||
}}>
|
||||
{/* User Info */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginRight: 'auto',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
':hover': { backgroundColor: 'rgba(255,255,255,0.05)' }
|
||||
}}>
|
||||
{showStatusMenu && (
|
||||
<div className="status-menu">
|
||||
{STATUS_OPTIONS.map(opt => (
|
||||
<div
|
||||
key={opt.value}
|
||||
className="status-menu-item"
|
||||
onClick={() => handleStatusChange(opt.value)}
|
||||
>
|
||||
<div className="status-menu-dot" style={{ backgroundColor: opt.color }} />
|
||||
<span>{opt.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="user-control-info" onClick={() => setShowStatusMenu(!showStatusMenu)}>
|
||||
<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>
|
||||
<Avatar username={username} size={32} />
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '-2px',
|
||||
@@ -122,41 +158,48 @@ const UserControlPanel = ({ username }) => {
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#3ba55c',
|
||||
border: '2px solid #292b2f'
|
||||
backgroundColor: statusColor,
|
||||
border: '2px solid var(--panel-bg)',
|
||||
cursor: 'pointer'
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ color: 'white', fontWeight: '600', fontSize: '14px', lineHeight: '18px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ color: 'var(--header-primary)', fontWeight: '600', fontSize: '14px', lineHeight: '18px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{username || 'Unknown'}
|
||||
</div>
|
||||
<div style={{ color: '#b9bbbe', fontSize: '12px', lineHeight: '13px' }}>
|
||||
#{Math.floor(Math.random() * 9000) + 1000}
|
||||
<div style={{ color: 'var(--header-secondary)', fontSize: '12px', lineHeight: '13px' }}>
|
||||
{STATUS_OPTIONS.find(s => s.value === currentStatus)?.label || 'Online'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div style={{ display: 'flex' }}>
|
||||
<button onClick={toggleMute} title={effectiveMute ? "Unmute" : "Mute"} style={controlButtonStyle}>
|
||||
<ColoredIcon
|
||||
src={effectiveMute ? mutedIcon : muteIcon}
|
||||
color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
|
||||
/>
|
||||
</button>
|
||||
<button onClick={toggleDeafen} title={isDeafened ? "Undeafen" : "Deafen"} style={controlButtonStyle}>
|
||||
<ColoredIcon
|
||||
src={isDeafened ? defeanedIcon : defeanIcon}
|
||||
color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
|
||||
/>
|
||||
</button>
|
||||
<button title="User Settings" style={controlButtonStyle}>
|
||||
<ColoredIcon
|
||||
src={settingsIcon}
|
||||
color={ICON_COLOR_DEFAULT}
|
||||
/>
|
||||
</button>
|
||||
<Tooltip text={effectiveMute ? "Unmute" : "Mute"} position="top">
|
||||
<button onClick={toggleMute} style={controlButtonStyle}>
|
||||
<ColoredIcon
|
||||
src={effectiveMute ? mutedIcon : muteIcon}
|
||||
color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip text={isDeafened ? "Undeafen" : "Deafen"} position="top">
|
||||
<button onClick={toggleDeafen} style={controlButtonStyle}>
|
||||
<ColoredIcon
|
||||
src={isDeafened ? defeanedIcon : defeanIcon}
|
||||
color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip text="User Settings" position="top">
|
||||
<button style={controlButtonStyle} onClick={() => setShowThemeSelector(true)}>
|
||||
<ColoredIcon
|
||||
src={settingsIcon}
|
||||
color={ICON_COLOR_DEFAULT}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{showThemeSelector && <ThemeSelector onClose={() => setShowThemeSelector(false)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -166,7 +209,7 @@ const UserControlPanel = ({ username }) => {
|
||||
const headerButtonStyle = {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#b9bbbe',
|
||||
color: 'var(--header-secondary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '18px',
|
||||
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 [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
|
||||
const [newChannelName, setNewChannelName] = useState('');
|
||||
const [newChannelType, setNewChannelType] = useState('text');
|
||||
const [editingChannel, setEditingChannel] = useState(null);
|
||||
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
||||
const [collapsedCategories, setCollapsedCategories] = useState({});
|
||||
|
||||
const convex = useConvex();
|
||||
|
||||
@@ -419,23 +463,22 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
<div style={{ marginLeft: 32, marginBottom: 8 }}>
|
||||
{users.map(user => (
|
||||
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: '50%',
|
||||
backgroundColor: '#5865F2',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
marginRight: 8, fontSize: 10, color: 'white',
|
||||
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
|
||||
}}>
|
||||
{user.username.substring(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<span style={{ color: '#b9bbbe', fontSize: 14 }}>{user.username}</span>
|
||||
<Avatar
|
||||
username={user.username}
|
||||
size={24}
|
||||
style={{
|
||||
marginRight: 8,
|
||||
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{user.username}</span>
|
||||
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center' }}>
|
||||
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
|
||||
{(user.isMuted || user.isDeafened) && (
|
||||
<ColoredIcon src={mutedIcon} color="#b9bbbe" size="14px" />
|
||||
<ColoredIcon src={mutedIcon} color="var(--header-secondary)" size="14px" />
|
||||
)}
|
||||
{user.isDeafened && (
|
||||
<ColoredIcon src={defeanedIcon} color="#b9bbbe" size="14px" />
|
||||
<ColoredIcon src={defeanedIcon} color="var(--header-secondary)" size="14px" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -444,113 +487,129 @@ 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 = () => (
|
||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<div className="channel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span
|
||||
style={{ cursor: 'pointer', maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
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 style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="server-header" onClick={() => setIsServerSettingsOpen(true)}>
|
||||
<span>Secure Chat</span>
|
||||
<span className="server-header-chevron">▾</span>
|
||||
</div>
|
||||
|
||||
{isCreating && (
|
||||
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
|
||||
<form onSubmit={handleSubmitCreate}>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||||
<label style={{ color: newChannelType==='text'?'white':'#b9bbbe', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('text')}>
|
||||
Text
|
||||
</label>
|
||||
<label style={{ color: newChannelType==='voice'?'white':'#b9bbbe', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('voice')}>
|
||||
Voice
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={`new-${newChannelType}-channel`}
|
||||
value={newChannelName}
|
||||
onChange={(e) => setNewChannelName(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: '#202225',
|
||||
border: '1px solid #7289da',
|
||||
borderRadius: '4px',
|
||||
color: '#dcddde',
|
||||
padding: '4px 8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none'
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
<div style={{ fontSize: 10, color: '#b9bbbe', marginTop: 2, textAlign: 'right' }}>
|
||||
Press Enter to Create {newChannelType === 'voice' && '(Voice)'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channels.map(channel => (
|
||||
<React.Fragment key={channel._id}>
|
||||
<div
|
||||
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
|
||||
onClick={() => handleChannelClick(channel)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingRight: '8px'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden', flex: 1 }}>
|
||||
{channel.type === 'voice' ? (
|
||||
<div style={{ marginRight: 6 }}>
|
||||
<ColoredIcon
|
||||
src={voiceIcon}
|
||||
size="16px"
|
||||
color={voiceStates[channel._id]?.length > 0 ? VOICE_ACTIVE_COLOR : "#8e9297"}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: '#8e9297', marginRight: '6px', flexShrink: 0 }}>#</span>
|
||||
)}
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{channel.name}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="channel-settings-icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingChannel(channel);
|
||||
}}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#b9bbbe',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
padding: '2px 4px',
|
||||
display: 'flex', alignItems: 'center',
|
||||
opacity: '0.7',
|
||||
transition: 'opacity 0.2s'
|
||||
}}
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{isCreating && (
|
||||
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
|
||||
<form onSubmit={handleSubmitCreate}>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||||
<label style={{ color: newChannelType==='text'?'white':'var(--interactive-normal)', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('text')}>
|
||||
Text
|
||||
</label>
|
||||
<label style={{ color: newChannelType==='voice'?'white':'var(--interactive-normal)', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('voice')}>
|
||||
Voice
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder={`new-${newChannelType}-channel`}
|
||||
value={newChannelName}
|
||||
onChange={(e) => setNewChannelName(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--brand-experiment)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--text-normal)',
|
||||
padding: '4px 8px',
|
||||
fontSize: '14px',
|
||||
outline: 'none'
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 2, textAlign: 'right' }}>
|
||||
Press Enter to Create {newChannelType === 'voice' && '(Voice)'}
|
||||
</div>
|
||||
</div>
|
||||
{renderVoiceUsers(channel)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
)}
|
||||
|
||||
{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}>
|
||||
<div
|
||||
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
|
||||
onClick={() => handleChannelClick(channel)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingRight: '8px'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden', flex: 1 }}>
|
||||
{channel.type === 'voice' ? (
|
||||
<div style={{ marginRight: 6 }}>
|
||||
<ColoredIcon
|
||||
src={voiceIcon}
|
||||
size="16px"
|
||||
color={voiceStates[channel._id]?.length > 0 ? VOICE_ACTIVE_COLOR : "var(--interactive-normal)"}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: 'var(--interactive-normal)', marginRight: '6px', flexShrink: 0 }}>#</span>
|
||||
)}
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{channel.name}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="channel-settings-icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingChannel(channel);
|
||||
}}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--interactive-normal)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
padding: '2px 4px',
|
||||
display: 'flex', alignItems: 'center',
|
||||
opacity: '0',
|
||||
transition: 'opacity 0.2s'
|
||||
}}
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
</div>
|
||||
{renderVoiceUsers(channel)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -558,26 +617,37 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
<div className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||
<div className="server-list">
|
||||
<div
|
||||
className={`server-icon ${view === 'me' ? 'active' : ''}`}
|
||||
onClick={() => onViewChange('me')}
|
||||
style={{
|
||||
backgroundColor: view === 'me' ? '#5865F2' : '#36393f',
|
||||
color: view === 'me' ? '#fff' : '#dcddde',
|
||||
marginBottom: '8px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<svg width="28" height="20" viewBox="0 0 28 20">
|
||||
<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>
|
||||
<div className="server-item-wrapper">
|
||||
<div className={`server-pill ${view === 'me' ? 'active' : ''}`} />
|
||||
<Tooltip text="Direct Messages" position="right">
|
||||
<div
|
||||
className={`server-icon ${view === 'me' ? 'active' : ''}`}
|
||||
onClick={() => onViewChange('me')}
|
||||
style={{
|
||||
backgroundColor: view === 'me' ? 'var(--brand-experiment)' : 'var(--bg-primary)',
|
||||
color: view === 'me' ? '#fff' : 'var(--text-normal)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<svg width="28" height="20" viewBox="0 0 28 20">
|
||||
<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>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`server-icon ${view === 'server' ? 'active' : ''}`}
|
||||
onClick={() => onViewChange('server')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>Sc</div>
|
||||
<div className="server-separator" />
|
||||
|
||||
<div className="server-item-wrapper">
|
||||
<div className={`server-pill ${view === 'server' ? 'active' : ''}`} />
|
||||
<Tooltip text="Secure Chat" position="right">
|
||||
<div
|
||||
className={`server-icon ${view === 'server' ? 'active' : ''}`}
|
||||
onClick={() => onViewChange('server')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>Sc</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === 'me' ? renderDMView() : renderServerView()}
|
||||
@@ -585,7 +655,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
|
||||
{connectionState === 'connected' && (
|
||||
<div style={{
|
||||
backgroundColor: '#292b2f',
|
||||
backgroundColor: 'var(--panel-bg)',
|
||||
borderRadius: '8px 8px 0px 0px',
|
||||
padding: 'calc(16px - 8px + 4px)',
|
||||
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'
|
||||
}}
|
||||
>
|
||||
<ColoredIcon src={disconnectIcon} color="#b9bbbe" size="20px" />
|
||||
<ColoredIcon src={disconnectIcon} color="var(--header-secondary)" size="20px" />
|
||||
</button>
|
||||
</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 }}>
|
||||
<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 onClick={handleScreenShareClick} title="Share Screen" style={voicePanelButtonStyle}>
|
||||
<ColoredIcon src={screenIcon} color="#b9bbbe" size="20px" />
|
||||
<ColoredIcon src={screenIcon} color="var(--header-secondary)" size="20px" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UserControlPanel username={username} />
|
||||
<UserControlPanel username={username} userId={userId} />
|
||||
|
||||
{editingChannel && (
|
||||
<ChannelSettingsModal
|
||||
|
||||
56
Frontend/Electron/src/components/ThemeSelector.jsx
Normal file
56
Frontend/Electron/src/components/ThemeSelector.jsx
Normal 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;
|
||||
61
Frontend/Electron/src/components/TitleBar.jsx
Normal file
61
Frontend/Electron/src/components/TitleBar.jsx
Normal 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;
|
||||
69
Frontend/Electron/src/components/Toast.jsx
Normal file
69
Frontend/Electron/src/components/Toast.jsx
Normal 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); }}>
|
||||
×
|
||||
</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;
|
||||
90
Frontend/Electron/src/components/Tooltip.jsx
Normal file
90
Frontend/Electron/src/components/Tooltip.jsx
Normal 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;
|
||||
133
Frontend/Electron/src/components/UserProfilePopup.jsx
Normal file
133
Frontend/Electron/src/components/UserProfilePopup.jsx
Normal 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;
|
||||
42
Frontend/Electron/src/contexts/ThemeContext.jsx
Normal file
42
Frontend/Electron/src/contexts/ThemeContext.jsx
Normal 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
@@ -3,20 +3,26 @@ import ReactDOM from 'react-dom/client';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import { ConvexProvider, ConvexReactClient } from 'convex/react';
|
||||
import App from './App';
|
||||
import './styles/themes.css';
|
||||
import './index.css';
|
||||
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { VoiceProvider } from './contexts/VoiceContext';
|
||||
import TitleBar from './components/TitleBar';
|
||||
|
||||
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<ConvexProvider client={convex}>
|
||||
<VoiceProvider>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</VoiceProvider>
|
||||
</ConvexProvider>
|
||||
<ThemeProvider>
|
||||
<ConvexProvider client={convex}>
|
||||
<VoiceProvider>
|
||||
<TitleBar />
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</VoiceProvider>
|
||||
</ConvexProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -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 { api } from '../../../../convex/_generated/api';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
@@ -6,6 +6,9 @@ import ChatArea from '../components/ChatArea';
|
||||
import VoiceStage from '../components/VoiceStage';
|
||||
import { useVoice } from '../contexts/VoiceContext';
|
||||
import FriendsView from '../components/FriendsView';
|
||||
import MembersList from '../components/MembersList';
|
||||
import ChatHeader from '../components/ChatHeader';
|
||||
import { useToasts } from '../components/Toast';
|
||||
|
||||
const Chat = () => {
|
||||
const [view, setView] = useState('server');
|
||||
@@ -14,8 +17,29 @@ const Chat = () => {
|
||||
const [userId, setUserId] = useState(null);
|
||||
const [channelKeys, setChannelKeys] = useState({});
|
||||
const [activeDMChannel, setActiveDMChannel] = useState(null);
|
||||
const [showMembers, setShowMembers] = useState(true);
|
||||
const [showPinned, setShowPinned] = useState(false);
|
||||
|
||||
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) || [];
|
||||
|
||||
@@ -113,20 +137,46 @@ const Chat = () => {
|
||||
}
|
||||
}, [convex]);
|
||||
|
||||
const handleSelectChannel = useCallback((channelId) => {
|
||||
setActiveChannel(channelId);
|
||||
setShowPinned(false);
|
||||
}, []);
|
||||
|
||||
const activeChannelObj = channels.find(c => c._id === activeChannel);
|
||||
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() {
|
||||
if (view === 'me') {
|
||||
if (activeDMChannel) {
|
||||
return (
|
||||
<ChatArea
|
||||
channelId={activeDMChannel.channel_id}
|
||||
channelName={activeDMChannel.other_username}
|
||||
channelKey={channelKeys[activeDMChannel.channel_id]}
|
||||
username={username}
|
||||
userId={userId}
|
||||
/>
|
||||
<div className="chat-container">
|
||||
<ChatHeader
|
||||
channelName={activeDMChannel.other_username}
|
||||
channelType="dm"
|
||||
onToggleMembers={() => {}}
|
||||
showMembers={false}
|
||||
onTogglePinned={() => setShowPinned(p => !p)}
|
||||
/>
|
||||
<div className="chat-content">
|
||||
<ChatArea
|
||||
channelId={activeDMChannel.channel_id}
|
||||
channelName={activeDMChannel.other_username}
|
||||
channelType="dm"
|
||||
channelKey={channelKeys[activeDMChannel.channel_id]}
|
||||
username={username}
|
||||
userId={userId}
|
||||
showMembers={false}
|
||||
onToggleMembers={() => {}}
|
||||
onOpenDM={openDM}
|
||||
showPinned={showPinned}
|
||||
onTogglePinned={() => setShowPinned(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <FriendsView onOpenDM={openDM} />;
|
||||
@@ -137,13 +187,37 @@ const Chat = () => {
|
||||
return <VoiceStage room={room} channelId={activeChannel} voiceStates={voiceStates} channelName={activeChannelObj?.name} />;
|
||||
}
|
||||
return (
|
||||
<ChatArea
|
||||
channelId={activeChannel}
|
||||
channelName={activeChannelObj?.name || activeChannel}
|
||||
channelKey={channelKeys[activeChannel]}
|
||||
username={username}
|
||||
userId={userId}
|
||||
/>
|
||||
<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
|
||||
channelId={activeChannel}
|
||||
channelName={activeChannelObj?.name || activeChannel}
|
||||
channelType="text"
|
||||
channelKey={channelKeys[activeChannel]}
|
||||
username={username}
|
||||
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
|
||||
channels={channels}
|
||||
activeChannel={activeChannel}
|
||||
onSelectChannel={setActiveChannel}
|
||||
onSelectChannel={handleSelectChannel}
|
||||
username={username}
|
||||
channelKeys={channelKeys}
|
||||
view={view}
|
||||
@@ -170,8 +244,10 @@ const Chat = () => {
|
||||
activeDMChannel={activeDMChannel}
|
||||
setActiveDMChannel={setActiveDMChannel}
|
||||
dmChannels={dmChannels}
|
||||
userId={userId}
|
||||
/>
|
||||
{renderMainContent()}
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
360
Frontend/Electron/src/styles/themes.css
Normal file
360
Frontend/Electron/src/styles/themes.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user