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

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

View File

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

1
.gitignore vendored
View File

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

View File

@@ -14,9 +14,10 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
## Key Convex Files (convex/)
- `schema.ts` - Full schema: userProfiles, channels, messages, messageReactions, channelKeys, roles, userRoles, invites, dmParticipants, typingIndicators, voiceStates
- `auth.ts` - getSalt, verifyUser, createUserWithProfile, getPublicKeys
- `channels.ts` - list, get, create, rename, remove (with cascade delete)
- `schema.ts` - Full schema: userProfiles (with avatarStorageId, aboutMe, customStatus), channels (with category, topic, position), messages, messageReactions, channelKeys, roles, userRoles, invites, dmParticipants, typingIndicators, voiceStates
- `auth.ts` - getSalt, verifyUser, createUserWithProfile, getPublicKeys (includes avatarUrl, aboutMe, customStatus), updateProfile, updateStatus
- `channels.ts` - list, get, create (with category/topic/position), rename, remove (cascade), updateTopic
- `members.ts` - getChannelMembers (includes isHoist on roles, avatarUrl, aboutMe, customStatus)
- `channelKeys.ts` - uploadKeys, getKeysForUser
- `messages.ts` - list (with reactions + username), send, remove
- `reactions.ts` - add, remove
@@ -40,6 +41,7 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
- `contexts/VoiceContext.jsx` - Voice state via Convex + LiveKit room management
- `components/ChannelSettingsModal.jsx` - Channel rename/delete via Convex mutations
- `components/ServerSettingsModal.jsx` - Role management via Convex queries/mutations
- `components/Avatar.jsx` - Reusable avatar component (image or colored-initial fallback)
- `components/FriendsView.jsx` - User list via Convex query
- `components/DMList.jsx` - DM user picker via Convex query
- `components/GifPicker.jsx` - GIF search via Convex action
@@ -56,6 +58,15 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
- Convex queries are reactive - no need for manual refresh or socket listeners
- File uploads use Convex storage: `generateUploadUrl` -> POST blob -> `getFileUrl`
- Typing indicators use scheduled functions for TTL cleanup
- CSS uses Discord dark theme colors via `:root` variables (`--bg-primary: #313338`, `--bg-secondary: #2b2d31`, `--bg-tertiary: #1e1f22`)
- Sidebar width is 312px (72px server strip + 240px channel panel)
- Channels are grouped by `category` field with collapsible headers
- Members list groups by hoisted roles (isHoist) then Online/Offline
- Avatar component supports both image URLs and colored-initial fallback
- Title bar has back/forward navigation arrows
- Chat header includes thread, pin, members, notification icons + channel topic
- Voice connected panel includes elapsed time timer
- Keyboard shortcuts: Ctrl+K (quick switcher), Ctrl+Shift+M (mute toggle)
## Environment Variables

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,10 +5,16 @@
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
<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>

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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'),
});

View File

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

View File

@@ -39,10 +39,10 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
left: 0,
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',

View File

@@ -7,8 +7,8 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import {
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">
<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' }}>
{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 && (
)}
{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)}>&times;</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);
// 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'); }}>
<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>
);
};

View File

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

View File

@@ -1,16 +1,26 @@
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 DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
const [showUserPicker, setShowUserPicker] = useState(false);
const [allUsers, setAllUsers] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const searchRef = useRef(null);
const STATUS_COLORS = {
online: '#3ba55c',
idle: '#faa61a',
dnd: '#ed4245',
invisible: '#747f8d',
offline: '#747f8d',
};
const convex = useConvex();
const STATUS_LABELS = {
online: 'Online',
idle: 'Idle',
dnd: 'Do Not Disturb',
invisible: 'Offline',
offline: 'Offline',
};
const getUserColor = (username) => {
const getUserColor = (username) => {
if (!username) return '#5865F2';
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
let hash = 0;
@@ -18,12 +28,21 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
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 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'
{/* 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);
}}
>
Find or start a conversation
</button>
<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>
<Tooltip text="New DM" position="top">
<span
style={{ cursor: 'pointer', fontSize: '16px' }}
onClick={handleOpenUserPicker}
title="New DM"
>+</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>
<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: '#3ba55c',
border: '2px solid #2f3136'
backgroundColor: STATUS_COLORS[status] || STATUS_COLORS.online,
border: '2px solid var(--bg-secondary)'
}} />
</div>
<div style={{ overflow: 'hidden' }}>
<div style={{ color: isActive ? '#fff' : '#dcddde', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', fontWeight: '500' }}>
<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>
<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>
)}

View File

@@ -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>

View File

@@ -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} />
) : (

View File

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

View File

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

View File

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

View File

@@ -67,7 +67,7 @@ const ServerSettingsModal = ({ onClose }) => {
display: 'flex', flexDirection: 'column', alignItems: 'flex-end', padding: '60px 6px 60px 20px'
}}>
<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>

View File

@@ -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)' }
}}>
<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()}
{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' }}>
<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}>
<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>
<button onClick={toggleDeafen} title={isDeafened ? "Undeafen" : "Deafen"} style={controlButtonStyle}>
</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>
<button title="User Settings" style={controlButtonStyle}>
</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',
<Avatar
username={user.username}
size={24}
style={{
marginRight: 8,
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>
}}
/>
<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,34 +487,38 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
);
};
const toggleCategory = (cat) => {
setCollapsedCategories(prev => ({ ...prev, [cat]: !prev[cat] }));
};
const groupedChannels = React.useMemo(() => {
const groups = {};
channels.forEach(ch => {
const cat = ch.type === 'voice'
? (ch.category || 'Voice Channels')
: (ch.category || 'Text Channels');
if (!groups[cat]) groups[cat] = [];
groups[cat].push(ch);
});
return groups;
}, [channels]);
const renderServerView = () => (
<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>
<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':'#b9bbbe', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('text')}>
<label style={{ color: newChannelType==='text'?'white':'var(--interactive-normal)', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('text')}>
Text
</label>
<label style={{ color: newChannelType==='voice'?'white':'#b9bbbe', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('voice')}>
<label style={{ color: newChannelType==='voice'?'white':'var(--interactive-normal)', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('voice')}>
Voice
</label>
</div>
@@ -483,23 +530,32 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
onChange={(e) => setNewChannelName(e.target.value)}
style={{
width: '100%',
background: '#202225',
border: '1px solid #7289da',
background: 'var(--bg-tertiary)',
border: '1px solid var(--brand-experiment)',
borderRadius: '4px',
color: '#dcddde',
color: 'var(--text-normal)',
padding: '4px 8px',
fontSize: '14px',
outline: 'none'
}}
/>
</form>
<div style={{ fontSize: 10, color: '#b9bbbe', marginTop: 2, textAlign: 'right' }}>
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 2, textAlign: 'right' }}>
Press Enter to Create {newChannelType === 'voice' && '(Voice)'}
</div>
</div>
)}
{channels.map(channel => (
{Object.entries(groupedChannels).map(([category, catChannels]) => (
<div key={category}>
<div className="channel-category-header" onClick={() => toggleCategory(category)}>
<span className={`category-chevron ${collapsedCategories[category] ? 'collapsed' : ''}`}></span>
<span className="category-label">{category}</span>
<button className="category-add-btn" onClick={(e) => { e.stopPropagation(); handleStartCreate(); }} title="Create Channel">
+
</button>
</div>
{!collapsedCategories[category] && catChannels.map(channel => (
<React.Fragment key={channel._id}>
<div
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
@@ -518,11 +574,11 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
<ColoredIcon
src={voiceIcon}
size="16px"
color={voiceStates[channel._id]?.length > 0 ? VOICE_ACTIVE_COLOR : "#8e9297"}
color={voiceStates[channel._id]?.length > 0 ? VOICE_ACTIVE_COLOR : "var(--interactive-normal)"}
/>
</div>
) : (
<span style={{ color: '#8e9297', marginRight: '6px', flexShrink: 0 }}>#</span>
<span style={{ color: 'var(--interactive-normal)', marginRight: '6px', flexShrink: 0 }}>#</span>
)}
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{channel.name}</span>
</div>
@@ -536,12 +592,12 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
style={{
background: 'transparent',
border: 'none',
color: '#b9bbbe',
color: 'var(--interactive-normal)',
cursor: 'pointer',
fontSize: '12px',
padding: '2px 4px',
display: 'flex', alignItems: 'center',
opacity: '0.7',
opacity: '0',
transition: 'opacity 0.2s'
}}
>
@@ -552,19 +608,24 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</React.Fragment>
))}
</div>
))}
</div>
</div>
);
return (
<div className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div className="server-list">
<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' ? '#5865F2' : '#36393f',
color: view === 'me' ? '#fff' : '#dcddde',
marginBottom: '8px',
backgroundColor: view === 'me' ? 'var(--brand-experiment)' : 'var(--bg-primary)',
color: view === 'me' ? '#fff' : 'var(--text-normal)',
cursor: 'pointer'
}}
>
@@ -572,12 +633,21 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
<path fill="currentColor" d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.0172 1.11817 13.0907 1.11817 11.1372 1.4184C10.9331 0.934541 10.7018 0.461742 10.4496 0C8.59007 0.318797 6.78726 0.879656 5.0768 1.67671C1.65217 6.84883 0.706797 11.8997 1.166 16.858C3.39578 18.5135 5.56066 19.5165 7.6473 20.1417C8.16912 19.4246 8.63212 18.6713 9.02347 17.8863C8.25707 17.5929 7.52187 17.2415 6.82914 16.8374C7.0147 16.6975 7.19515 16.5518 7.36941 16.402C11.66 18.396 16.4523 18.396 20.7186 16.402C20.8953 16.5518 21.0758 16.6975 21.2613 16.8374C20.5663 17.2435 19.8288 17.5947 19.0624 17.8863C19.4537 18.6713 19.9167 19.4246 20.4385 20.1417C22.5276 19.5165 24.6925 18.5135 26.9223 16.858C27.4684 11.2365 25.9961 6.22055 23.0212 1.67671ZM9.68041 13.6383C8.39754 13.6383 7.34085 12.4456 7.34085 10.994C7.34085 9.5424 8.37555 8.34973 9.68041 8.34973C10.9893 8.34973 12.0395 9.5424 12.0175 10.994C12.0175 12.4456 10.9893 13.6383 9.68041 13.6383ZM18.3161 13.6383C17.0332 13.6383 15.9765 12.4456 15.9765 10.994C15.9765 9.5424 17.0112 8.34973 18.3161 8.34973C19.625 8.34973 20.6752 9.5424 20.6532 10.994C20.6532 12.4456 19.625 13.6383 18.3161 13.6383Z"/>
</svg>
</div>
</Tooltip>
</div>
<div className="server-separator" />
<div className="server-item-wrapper">
<div className={`server-pill ${view === 'server' ? 'active' : ''}`} />
<Tooltip text="Secure Chat" position="right">
<div
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -3,20 +3,26 @@ import ReactDOM from 'react-dom/client';
import { HashRouter } from 'react-router-dom';
import { 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>
<ThemeProvider>
<ConvexProvider client={convex}>
<VoiceProvider>
<TitleBar />
<HashRouter>
<App />
</HashRouter>
</VoiceProvider>
</ConvexProvider>
</ThemeProvider>
</React.StrictMode>,
);

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useQuery, useConvex } from 'convex/react';
import { 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 (
<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 (
<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>
);
};

View File

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

7
TODO.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

63
convex/members.ts Normal file
View File

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

View File

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

View File

@@ -11,11 +11,18 @@ export default defineSchema({
publicSigningKey: v.string(),
encryptedPrivateKeys: v.string(),
isAdmin: v.boolean(),
status: v.optional(v.string()),
avatarStorageId: v.optional(v.id("_storage")),
aboutMe: v.optional(v.string()),
customStatus: v.optional(v.string()),
}).index("by_username", ["username"]),
channels: defineTable({
name: v.string(),
type: v.string(), // 'text' | 'voice' | 'dm'
category: v.optional(v.string()),
topic: v.optional(v.string()),
position: v.optional(v.number()),
}).index("by_name", ["name"]),
messages: defineTable({
@@ -25,6 +32,9 @@ export default defineSchema({
nonce: v.string(),
signature: v.string(),
keyVersion: v.number(),
replyTo: v.optional(v.id("messages")),
editedAt: v.optional(v.number()),
pinned: v.optional(v.boolean()),
}).index("by_channel", ["channelId"]),
messageReactions: defineTable({

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long