feat: Initialize the Electron frontend with core UI components and integrate Convex backend services.
This commit is contained in:
@@ -11,7 +11,9 @@
|
|||||||
"Bash(npx convex:*)",
|
"Bash(npx convex:*)",
|
||||||
"Bash(npx @convex-dev/auth:*)",
|
"Bash(npx @convex-dev/auth:*)",
|
||||||
"Bash(dir:*)",
|
"Bash(dir:*)",
|
||||||
"Bash(npx vite build:*)"
|
"Bash(npx vite build:*)",
|
||||||
|
"Bash(npx tsc:*)",
|
||||||
|
"Bash(npx -y esbuild:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ node_modules
|
|||||||
.env.local
|
.env.local
|
||||||
.vscode
|
.vscode
|
||||||
./backend/uploads/
|
./backend/uploads/
|
||||||
|
./discord-html-copy
|
||||||
17
CLAUDE.md
17
CLAUDE.md
@@ -14,9 +14,10 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
|
|||||||
|
|
||||||
## Key Convex Files (convex/)
|
## Key Convex Files (convex/)
|
||||||
|
|
||||||
- `schema.ts` - Full schema: userProfiles, channels, messages, messageReactions, channelKeys, roles, userRoles, invites, dmParticipants, typingIndicators, voiceStates
|
- `schema.ts` - Full schema: userProfiles (with avatarStorageId, aboutMe, customStatus), channels (with category, topic, position), messages, messageReactions, channelKeys, roles, userRoles, invites, dmParticipants, typingIndicators, voiceStates
|
||||||
- `auth.ts` - getSalt, verifyUser, createUserWithProfile, getPublicKeys
|
- `auth.ts` - getSalt, verifyUser, createUserWithProfile, getPublicKeys (includes avatarUrl, aboutMe, customStatus), updateProfile, updateStatus
|
||||||
- `channels.ts` - list, get, create, rename, remove (with cascade delete)
|
- `channels.ts` - list, get, create (with category/topic/position), rename, remove (cascade), updateTopic
|
||||||
|
- `members.ts` - getChannelMembers (includes isHoist on roles, avatarUrl, aboutMe, customStatus)
|
||||||
- `channelKeys.ts` - uploadKeys, getKeysForUser
|
- `channelKeys.ts` - uploadKeys, getKeysForUser
|
||||||
- `messages.ts` - list (with reactions + username), send, remove
|
- `messages.ts` - list (with reactions + username), send, remove
|
||||||
- `reactions.ts` - add, remove
|
- `reactions.ts` - add, remove
|
||||||
@@ -40,6 +41,7 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
|
|||||||
- `contexts/VoiceContext.jsx` - Voice state via Convex + LiveKit room management
|
- `contexts/VoiceContext.jsx` - Voice state via Convex + LiveKit room management
|
||||||
- `components/ChannelSettingsModal.jsx` - Channel rename/delete via Convex mutations
|
- `components/ChannelSettingsModal.jsx` - Channel rename/delete via Convex mutations
|
||||||
- `components/ServerSettingsModal.jsx` - Role management via Convex queries/mutations
|
- `components/ServerSettingsModal.jsx` - Role management via Convex queries/mutations
|
||||||
|
- `components/Avatar.jsx` - Reusable avatar component (image or colored-initial fallback)
|
||||||
- `components/FriendsView.jsx` - User list via Convex query
|
- `components/FriendsView.jsx` - User list via Convex query
|
||||||
- `components/DMList.jsx` - DM user picker via Convex query
|
- `components/DMList.jsx` - DM user picker via Convex query
|
||||||
- `components/GifPicker.jsx` - GIF search via Convex action
|
- `components/GifPicker.jsx` - GIF search via Convex action
|
||||||
@@ -56,6 +58,15 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
|
|||||||
- Convex queries are reactive - no need for manual refresh or socket listeners
|
- Convex queries are reactive - no need for manual refresh or socket listeners
|
||||||
- File uploads use Convex storage: `generateUploadUrl` -> POST blob -> `getFileUrl`
|
- File uploads use Convex storage: `generateUploadUrl` -> POST blob -> `getFileUrl`
|
||||||
- Typing indicators use scheduled functions for TTL cleanup
|
- Typing indicators use scheduled functions for TTL cleanup
|
||||||
|
- CSS uses Discord dark theme colors via `:root` variables (`--bg-primary: #313338`, `--bg-secondary: #2b2d31`, `--bg-tertiary: #1e1f22`)
|
||||||
|
- Sidebar width is 312px (72px server strip + 240px channel panel)
|
||||||
|
- Channels are grouped by `category` field with collapsible headers
|
||||||
|
- Members list groups by hoisted roles (isHoist) then Online/Offline
|
||||||
|
- Avatar component supports both image URLs and colored-initial fallback
|
||||||
|
- Title bar has back/forward navigation arrows
|
||||||
|
- Chat header includes thread, pin, members, notification icons + channel topic
|
||||||
|
- Voice connected panel includes elapsed time timer
|
||||||
|
- Keyboard shortcuts: Ctrl+K (quick switcher), Ctrl+Shift+M (mute toggle)
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
|
|||||||
1
Frontend/Electron/dist-react/assets/index-0wNLL1lc.css
Normal file
1
Frontend/Electron/dist-react/assets/index-0wNLL1lc.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,10 +5,16 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>discord</title>
|
<title>discord</title>
|
||||||
<script type="module" crossorigin src="./assets/index-DXKRzYO-.js"></script>
|
<script type="module" crossorigin src="./assets/index-XO0EnFCR.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-D1fin5Al.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-0wNLL1lc.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var t = localStorage.getItem('discord-theme') || 'theme-dark';
|
||||||
|
document.documentElement.className = t;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
<title>discord</title>
|
<title>discord</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var t = localStorage.getItem('discord-theme') || 'theme-dark';
|
||||||
|
document.documentElement.className = t;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ function createWindow() {
|
|||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 800,
|
height: 800,
|
||||||
|
frame: false,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -30,6 +31,22 @@ function createWindow() {
|
|||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
||||||
|
ipcMain.on('window-minimize', () => {
|
||||||
|
const win = BrowserWindow.getFocusedWindow();
|
||||||
|
if (win) win.minimize();
|
||||||
|
});
|
||||||
|
ipcMain.on('window-maximize', () => {
|
||||||
|
const win = BrowserWindow.getFocusedWindow();
|
||||||
|
if (win) {
|
||||||
|
if (win.isMaximized()) win.unmaximize();
|
||||||
|
else win.maximize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.on('window-close', () => {
|
||||||
|
const win = BrowserWindow.getFocusedWindow();
|
||||||
|
if (win) win.close();
|
||||||
|
});
|
||||||
|
|
||||||
// Helper to fetch metadata (Zero-Knowledge: Client fetches previews)
|
// Helper to fetch metadata (Zero-Knowledge: Client fetches previews)
|
||||||
ipcMain.handle('fetch-metadata', async (event, url) => {
|
ipcMain.handle('fetch-metadata', async (event, url) => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
|||||||
@@ -18,3 +18,9 @@ contextBridge.exposeInMainWorld('cryptoAPI', {
|
|||||||
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
||||||
getScreenSources: () => ipcRenderer.invoke('get-screen-sources'),
|
getScreenSources: () => ipcRenderer.invoke('get-screen-sources'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('windowControls', {
|
||||||
|
minimize: () => ipcRenderer.send('window-minimize'),
|
||||||
|
maximize: () => ipcRenderer.send('window-maximize'),
|
||||||
|
close: () => ipcRenderer.send('window-close'),
|
||||||
|
});
|
||||||
|
|||||||
62
Frontend/Electron/src/components/Avatar.jsx
Normal file
62
Frontend/Electron/src/components/Avatar.jsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||||
|
|
||||||
|
function getUserColor(name) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < (name || '').length; i++) {
|
||||||
|
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
const Avatar = ({ username, avatarUrl, size = 40, className = '', style = {}, onClick }) => {
|
||||||
|
const sizeStr = `${size}px`;
|
||||||
|
const fontSize = `${Math.max(size * 0.45, 10)}px`;
|
||||||
|
|
||||||
|
if (avatarUrl) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className={className}
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={username || '?'}
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
width: sizeStr,
|
||||||
|
height: sizeStr,
|
||||||
|
borderRadius: '50%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
cursor: onClick ? 'pointer' : 'default',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
width: sizeStr,
|
||||||
|
height: sizeStr,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: getUserColor(username || 'U'),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize,
|
||||||
|
userSelect: 'none',
|
||||||
|
flexShrink: 0,
|
||||||
|
cursor: onClick ? 'pointer' : 'default',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(username || '?').substring(0, 1).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Avatar;
|
||||||
@@ -39,10 +39,10 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
backgroundColor: 'hsl(240 calc(1*7.143%) 10.98% /1)',
|
backgroundColor: 'var(--bg-primary)',
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
color: '#dcddde'
|
color: 'var(--text-normal)'
|
||||||
}}>
|
}}>
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -57,7 +57,7 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
|||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#96989d',
|
color: 'var(--text-muted)',
|
||||||
marginBottom: '6px',
|
marginBottom: '6px',
|
||||||
textTransform: 'uppercase'
|
textTransform: 'uppercase'
|
||||||
}}>
|
}}>
|
||||||
@@ -69,8 +69,8 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
|||||||
style={{
|
style={{
|
||||||
padding: '6px 10px',
|
padding: '6px 10px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
backgroundColor: activeTab === 'Overview' ? '#393c43' : 'transparent',
|
backgroundColor: activeTab === 'Overview' ? 'var(--background-modifier-selected)' : 'transparent',
|
||||||
color: activeTab === 'Overview' ? '#fff' : '#b9bbbe',
|
color: activeTab === 'Overview' ? 'var(--header-primary)' : 'var(--header-secondary)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
marginBottom: '2px',
|
marginBottom: '2px',
|
||||||
fontSize: '15px'
|
fontSize: '15px'
|
||||||
@@ -79,7 +79,7 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
|||||||
Overview
|
Overview
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ height: '1px', backgroundColor: '#3f4147', margin: '8px 0' }} />
|
<div style={{ height: '1px', backgroundColor: 'var(--border-subtle)', margin: '8px 0' }} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onClick={() => setActiveTab('Delete')}
|
onClick={() => setActiveTab('Delete')}
|
||||||
@@ -102,18 +102,18 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div style={{ flex: 1, padding: '60px 40px', maxWidth: '740px' }}>
|
<div style={{ flex: 1, padding: '60px 40px', maxWidth: '740px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '20px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '20px' }}>
|
||||||
<h2 style={{ color: 'white', margin: 0 }}>
|
<h2 style={{ color: 'var(--header-primary)', margin: 0 }}>
|
||||||
{activeTab === 'Delete' ? 'Delete Channel' : 'Overview'}
|
{activeTab === 'Delete' ? 'Delete Channel' : 'Overview'}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{
|
style={{
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: '1px solid #b9bbbe',
|
border: '1px solid var(--header-secondary)',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
width: '36px',
|
width: '36px',
|
||||||
height: '36px',
|
height: '36px',
|
||||||
color: '#b9bbbe',
|
color: 'var(--header-secondary)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex', allignItems: 'center', justifyContent: 'center'
|
display: 'flex', allignItems: 'center', justifyContent: 'center'
|
||||||
}}
|
}}
|
||||||
@@ -127,7 +127,7 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
|||||||
<div style={{ marginBottom: '20px' }}>
|
<div style={{ marginBottom: '20px' }}>
|
||||||
<label style={{
|
<label style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
color: '#b9bbbe',
|
color: 'var(--header-secondary)',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
@@ -141,11 +141,11 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
|||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
backgroundColor: '#202225',
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
padding: '10px',
|
padding: '10px',
|
||||||
color: '#dcddde',
|
color: 'var(--text-normal)',
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
outline: 'none'
|
outline: 'none'
|
||||||
}}
|
}}
|
||||||
@@ -157,7 +157,7 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
|||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#3ba55c',
|
backgroundColor: '#3ba55c',
|
||||||
color: 'white',
|
color: 'var(--header-primary)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
padding: '10px 24px',
|
padding: '10px 24px',
|
||||||
@@ -173,16 +173,16 @@ const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'Delete' && (
|
{activeTab === 'Delete' && (
|
||||||
<div style={{ backgroundColor: '#202225', padding: '16px', borderRadius: '8px', border: '1px solid #ed4245' }}>
|
<div style={{ backgroundColor: 'var(--bg-tertiary)', padding: '16px', borderRadius: '8px', border: '1px solid #ed4245' }}>
|
||||||
<h3 style={{ color: 'white', marginTop: 0 }}>Are you sure?</h3>
|
<h3 style={{ color: 'var(--header-primary)', marginTop: 0 }}>Are you sure?</h3>
|
||||||
<p style={{ color: '#b9bbbe' }}>
|
<p style={{ color: 'var(--header-secondary)' }}>
|
||||||
Deleting <b>#{channel.name}</b> cannot be undone. All messages and keys will be lost forever.
|
Deleting <b>#{channel.name}</b> cannot be undone. All messages and keys will be lost forever.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#ed4245',
|
backgroundColor: '#ed4245',
|
||||||
color: 'white',
|
color: 'var(--header-primary)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
padding: '10px 24px',
|
padding: '10px 24px',
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|||||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
import {
|
import {
|
||||||
GifIcon,
|
GifIcon,
|
||||||
EmojieIcon,
|
|
||||||
StickerIcon,
|
StickerIcon,
|
||||||
|
EmojieIcon,
|
||||||
EmojiesColored,
|
EmojiesColored,
|
||||||
EmojiesGreyscale,
|
EmojiesGreyscale,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
@@ -26,6 +26,11 @@ const fireIcon = getEmojiUrl('nature', 'fire');
|
|||||||
const heartIcon = getEmojiUrl('symbols', 'heart');
|
const heartIcon = getEmojiUrl('symbols', 'heart');
|
||||||
const thumbsupIcon = getEmojiUrl('people', 'thumbsup');
|
const thumbsupIcon = getEmojiUrl('people', 'thumbsup');
|
||||||
import GifPicker from './GifPicker';
|
import GifPicker from './GifPicker';
|
||||||
|
import PinnedMessagesPanel from './PinnedMessagesPanel';
|
||||||
|
import Tooltip from './Tooltip';
|
||||||
|
import UserProfilePopup from './UserProfilePopup';
|
||||||
|
import Avatar from './Avatar';
|
||||||
|
import MentionMenu from './MentionMenu';
|
||||||
|
|
||||||
const metadataCache = new Map();
|
const metadataCache = new Map();
|
||||||
const attachmentCache = new Map();
|
const attachmentCache = new Map();
|
||||||
@@ -79,6 +84,20 @@ const formatEmojis = (text) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filterMembersForMention = (members, query) => {
|
||||||
|
if (!members) return [];
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
if (!q) return members;
|
||||||
|
const prefix = [];
|
||||||
|
const substring = [];
|
||||||
|
for (const m of members) {
|
||||||
|
const name = m.username.toLowerCase();
|
||||||
|
if (name.startsWith(q)) prefix.push(m);
|
||||||
|
else if (name.includes(q)) substring.push(m);
|
||||||
|
}
|
||||||
|
return [...prefix, ...substring];
|
||||||
|
};
|
||||||
|
|
||||||
const isNewDay = (current, previous) => {
|
const isNewDay = (current, previous) => {
|
||||||
if (!previous) return true;
|
if (!previous) return true;
|
||||||
return current.getDate() !== previous.getDate()
|
return current.getDate() !== previous.getDate()
|
||||||
@@ -234,7 +253,7 @@ const Attachment = ({ metadata, onLoad, onImageClick }) => {
|
|||||||
return () => { isMounted = false; };
|
return () => { isMounted = false; };
|
||||||
}, [metadata, onLoad]);
|
}, [metadata, onLoad]);
|
||||||
|
|
||||||
if (loading) return <div style={{ color: '#b9bbbe', fontSize: '12px' }}>Downloading & Decrypting...</div>;
|
if (loading) return <div style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>Downloading & Decrypting...</div>;
|
||||||
if (error) return <div style={{ color: '#ed4245', fontSize: '12px' }}>{error}</div>;
|
if (error) return <div style={{ color: '#ed4245', fontSize: '12px' }}>{error}</div>;
|
||||||
|
|
||||||
if (metadata.mimeType.startsWith('image/')) {
|
if (metadata.mimeType.startsWith('image/')) {
|
||||||
@@ -250,12 +269,12 @@ const Attachment = ({ metadata, onLoad, onImageClick }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', backgroundColor: '#2f3136', padding: '10px', borderRadius: '4px', maxWidth: '300px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--embed-background)', padding: '10px', borderRadius: '4px', maxWidth: '300px' }}>
|
||||||
<span style={{ marginRight: '10px', fontSize: '24px' }}>📄</span>
|
<span style={{ marginRight: '10px', fontSize: '24px' }}>📄</span>
|
||||||
<div style={{ overflow: 'hidden' }}>
|
<div style={{ overflow: 'hidden' }}>
|
||||||
<div style={{ color: '#00b0f4', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{metadata.filename}</div>
|
<div style={{ color: 'var(--text-link)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{metadata.filename}</div>
|
||||||
<div style={{ color: '#b9bbbe', fontSize: '12px' }}>{(metadata.size / 1024).toFixed(1)} KB</div>
|
<div style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>{(metadata.size / 1024).toFixed(1)} KB</div>
|
||||||
<a href={url} download={metadata.filename} style={{ color: '#b9bbbe', fontSize: '12px', textDecoration: 'underline' }}>Download</a>
|
<a href={url} download={metadata.filename} style={{ color: 'var(--header-secondary)', fontSize: '12px', textDecoration: 'underline' }}>Download</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -296,14 +315,14 @@ const PendingFilePreview = ({ file, onRemove }) => {
|
|||||||
previewContent = (
|
previewContent = (
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<div style={{ fontSize: '24px' }}>📄</div>
|
<div style={{ fontSize: '24px' }}>📄</div>
|
||||||
<div style={{ fontSize: '10px', color: '#b9bbbe', marginTop: '4px', maxWidth: '90px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</div>
|
<div style={{ fontSize: '10px', color: 'var(--header-secondary)', marginTop: '4px', maxWidth: '90px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'inline-flex', flexDirection: 'column', marginRight: '10px' }}>
|
<div style={{ display: 'inline-flex', flexDirection: 'column', marginRight: '10px' }}>
|
||||||
<div style={{ position: 'relative', width: '200px', height: '200px', borderRadius: '8px', backgroundColor: '#2f3136', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
|
<div style={{ position: 'relative', width: '200px', height: '200px', borderRadius: '8px', backgroundColor: 'var(--embed-background)', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
|
||||||
{previewContent}
|
{previewContent}
|
||||||
<div style={{ position: 'absolute', top: '4px', right: '4px', display: 'flex', gap: '4px', padding: '4px' }}>
|
<div style={{ position: 'absolute', top: '4px', right: '4px', display: 'flex', gap: '4px', padding: '4px' }}>
|
||||||
<ActionButton icon={SpoilerIcon} onClick={() => {}} />
|
<ActionButton icon={SpoilerIcon} onClick={() => {}} />
|
||||||
@@ -312,7 +331,7 @@ const PendingFilePreview = ({ file, onRemove }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{preview && (
|
{preview && (
|
||||||
<div style={{ fontSize: '11px', color: '#b9bbbe', marginTop: '4px', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</div>
|
<div style={{ fontSize: '11px', color: 'var(--header-secondary)', marginTop: '4px', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -339,27 +358,45 @@ const EmojiButton = ({ onClick, active }) => {
|
|||||||
return `-${col * 24}px -${row * 24}px`;
|
return `-${col * 24}px -${row * 24}px`;
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="chat-input-icon-btn" onClick={(e) => { e.stopPropagation(); if(onClick) onClick(); }} onMouseEnter={() => { setHovered(true); setBgPos(getRandomPos()); }} onMouseLeave={() => { setHovered(false); setBgPos(getRandomPos()); }} style={{ width: '24px', height: '24px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', marginLeft: '4px' }} title="Select Emoji">
|
<Tooltip text="Select Emoji" position="top">
|
||||||
<div style={{ width: '24px', height: '24px', backgroundImage: `url(${(hovered || active) ? EmojiesColored : EmojiesGreyscale})`, backgroundPosition: bgPos, backgroundSize: '480px 96px', backgroundRepeat: 'no-repeat' }} />
|
<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>
|
<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 }) => (
|
const MessageToolbar = ({ onAddReaction, onEdit, onReply, onMore, isOwner }) => (
|
||||||
<div style={{ position: 'absolute', top: '-40px', right: '16px', backgroundColor: 'color-mix(in oklab, hsl(240 calc(1*6.494%) 15.098% /1) 100%, #000 0%)', border: '1px solid color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.0392156862745098) 100%,hsl(0 0% 0% /0.0392156862745098) 0%)', borderRadius: '8px', boxShadow: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.0392156862745098) 100%,hsl(0 0% 0% /0.0392156862745098) 0%)', display: 'flex', alignItems: 'center', zIndex: 10, padding: '2px' }}>
|
<div className="message-toolbar">
|
||||||
<IconButton onClick={() => onAddReaction('thumbsup')} title="Add Reaction" emoji={<ColoredIcon src={thumbsupIcon} size="20px" />} />
|
<Tooltip text="Thumbs Up" position="top">
|
||||||
<IconButton onClick={() => onAddReaction('heart')} title="Add Reaction" emoji={<ColoredIcon src={heartIcon} size="20px" />} />
|
<IconButton onClick={() => onAddReaction('thumbsup')} emoji={<ColoredIcon src={thumbsupIcon} size="20px" />} />
|
||||||
<IconButton onClick={() => onAddReaction('fire')} title="Add Reaction" emoji={<ColoredIcon src={fireIcon} size="20px" />} />
|
</Tooltip>
|
||||||
|
<Tooltip text="Heart" position="top">
|
||||||
|
<IconButton onClick={() => onAddReaction('heart')} emoji={<ColoredIcon src={heartIcon} size="20px" />} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip text="Fire" position="top">
|
||||||
|
<IconButton onClick={() => onAddReaction('fire')} emoji={<ColoredIcon src={fireIcon} size="20px" />} />
|
||||||
|
</Tooltip>
|
||||||
<div style={{ width: '1px', height: '24px', margin: '2px 4px', backgroundColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%)' }}></div>
|
<div style={{ width: '1px', height: '24px', margin: '2px 4px', backgroundColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%)' }}></div>
|
||||||
<IconButton onClick={() => onAddReaction(null)} title="Add Reaction" emoji={<ColoredIcon src={EmojieIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
<Tooltip text="Add Reaction" position="top">
|
||||||
{isOwner && <IconButton onClick={onEdit} title="Edit" emoji={<ColoredIcon src={EditIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />}
|
<IconButton onClick={() => onAddReaction(null)} emoji={<ColoredIcon src={EmojieIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
||||||
<IconButton onClick={onReply} title="Reply" emoji={<ColoredIcon src={ReplyIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
</Tooltip>
|
||||||
<IconButton onClick={onMore} title="More" emoji={<ColoredIcon src={MoreIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
{isOwner && (
|
||||||
|
<Tooltip text="Edit" position="top">
|
||||||
|
<IconButton onClick={onEdit} emoji={<ColoredIcon src={EditIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip text="Reply" position="top">
|
||||||
|
<IconButton onClick={onReply} emoji={<ColoredIcon src={ReplyIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip text="More" position="top">
|
||||||
|
<IconButton onClick={onMore} emoji={<ColoredIcon src={MoreIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const IconButton = ({ onClick, title, emoji }) => (
|
const IconButton = ({ onClick, emoji }) => (
|
||||||
<div onClick={(e) => { e.stopPropagation(); onClick(e); }} title={title} style={{ cursor: 'pointer', padding: '6px', fontSize: '16px', lineHeight: 1, color: '#b9bbbe', transition: 'background-color 0.1s' }} onMouseEnter={(e) => e.target.style.backgroundColor = '#40444b'} onMouseLeave={(e) => e.target.style.backgroundColor = 'transparent'}>
|
<div onClick={(e) => { e.stopPropagation(); onClick(e); }} className="icon-button" style={{ cursor: 'pointer', padding: '6px', fontSize: '16px', lineHeight: 1, color: 'var(--header-secondary)', transition: 'background-color 0.1s' }}>
|
||||||
{emoji}
|
{emoji}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -380,20 +417,20 @@ const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner }) => {
|
|||||||
}, [x, y]);
|
}, [x, y]);
|
||||||
|
|
||||||
const MenuItem = ({ label, iconSrc, iconColor, onClick, danger }) => (
|
const MenuItem = ({ label, iconSrc, iconColor, onClick, danger }) => (
|
||||||
<div onClick={(e) => { e.stopPropagation(); onClick(); onClose(); }} style={{ display: 'flex', alignItems: 'center', padding: '10px 12px', cursor: 'pointer', fontSize: '14px', color: danger ? ICON_COLOR_DANGER : '#dcddde', justifyContent: 'space-between', whiteSpace: 'nowrap' }} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = danger ? 'color-mix(in oklab,hsl(355.636 calc(1*64.706%) 50% /0.0784313725490196) 100%,hsl(0 0% 0% /0.0784313725490196) 0%)' : 'hsl(240 calc(1*4%) 60.784% /0.0784313725490196)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
|
<div onClick={(e) => { e.stopPropagation(); onClick(); onClose(); }} className={`context-menu-item ${danger ? 'context-menu-item-danger' : ''}`}>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<div style={{ marginLeft: '12px' }}><ColoredIcon src={iconSrc} color={iconColor} size="18px" /></div>
|
<div style={{ marginLeft: '12px' }}><ColoredIcon src={iconSrc} color={iconColor} size="18px" /></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={menuRef} style={{ position: 'fixed', top: pos.top, left: pos.left, backgroundColor: '#18191c', borderRadius: '4px', boxShadow: '0 8px 16px rgba(0,0,0,0.24)', zIndex: 9999, minWidth: '188px', padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: '2px' }} onClick={(e) => e.stopPropagation()}>
|
<div ref={menuRef} className="context-menu" style={{ top: pos.top, left: pos.left }} onClick={(e) => e.stopPropagation()}>
|
||||||
<MenuItem label="Add Reaction" iconSrc={EmojieIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('reaction')} />
|
<MenuItem label="Add Reaction" iconSrc={EmojieIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('reaction')} />
|
||||||
{isOwner && <MenuItem label="Edit Message" iconSrc={EditIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('edit')} />}
|
{isOwner && <MenuItem label="Edit Message" iconSrc={EditIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('edit')} />}
|
||||||
<MenuItem label="Reply" iconSrc={ReplyIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('reply')} />
|
<MenuItem label="Reply" iconSrc={ReplyIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('reply')} />
|
||||||
<div style={{ height: '1px', backgroundColor: '#36393f', margin: '4px 0' }} />
|
<div className="context-menu-separator" />
|
||||||
<MenuItem label="Pin Message" iconSrc={PinIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('pin')} />
|
<MenuItem label="Pin Message" iconSrc={PinIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('pin')} />
|
||||||
<div style={{ height: '1px', backgroundColor: '#36393f', margin: '4px 0' }} />
|
<div className="context-menu-separator" />
|
||||||
{isOwner && <MenuItem label="Delete Message" iconSrc={DeleteIcon} iconColor={ICON_COLOR_DANGER} danger onClick={() => onInteract('delete')} />}
|
{isOwner && <MenuItem label="Delete Message" iconSrc={DeleteIcon} iconColor={ICON_COLOR_DANGER} danger onClick={() => onInteract('delete')} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -440,7 +477,17 @@ const parseAttachment = (content) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChatArea = ({ channelId, channelName, username, channelKey, userId: currentUserId }) => {
|
const parseSystemMessage = (content) => {
|
||||||
|
if (!content || !content.startsWith('{')) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
return parsed.type === 'system' ? parsed : null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChatArea = ({ channelId, channelName, channelType, username, channelKey, userId: currentUserId, showMembers, onToggleMembers, onOpenDM, showPinned, onTogglePinned }) => {
|
||||||
const [decryptedMessages, setDecryptedMessages] = useState([]);
|
const [decryptedMessages, setDecryptedMessages] = useState([]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [zoomedImage, setZoomedImage] = useState(null);
|
const [zoomedImage, setZoomedImage] = useState(null);
|
||||||
@@ -452,6 +499,12 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
const [hoveredMessageId, setHoveredMessageId] = useState(null);
|
const [hoveredMessageId, setHoveredMessageId] = useState(null);
|
||||||
const [contextMenu, setContextMenu] = useState(null);
|
const [contextMenu, setContextMenu] = useState(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [replyingTo, setReplyingTo] = useState(null);
|
||||||
|
const [editingMessage, setEditingMessage] = useState(null);
|
||||||
|
const [editInput, setEditInput] = useState('');
|
||||||
|
const [profilePopup, setProfilePopup] = useState(null);
|
||||||
|
const [mentionQuery, setMentionQuery] = useState(null);
|
||||||
|
const [mentionIndex, setMentionIndex] = useState(0);
|
||||||
|
|
||||||
const messagesEndRef = useRef(null);
|
const messagesEndRef = useRef(null);
|
||||||
const messagesContainerRef = useRef(null);
|
const messagesContainerRef = useRef(null);
|
||||||
@@ -470,6 +523,8 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
|
|
||||||
const convex = useConvex();
|
const convex = useConvex();
|
||||||
|
|
||||||
|
const members = useQuery(api.members.getChannelMembers, channelId ? { channelId } : "skip") || [];
|
||||||
|
|
||||||
const { results: rawMessages, status, loadMore } = usePaginatedQuery(
|
const { results: rawMessages, status, loadMore } = usePaginatedQuery(
|
||||||
api.messages.list,
|
api.messages.list,
|
||||||
channelId ? { channelId, userId: currentUserId || undefined } : "skip",
|
channelId ? { channelId, userId: currentUserId || undefined } : "skip",
|
||||||
@@ -482,6 +537,9 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
const sendMessageMutation = useMutation(api.messages.send);
|
const sendMessageMutation = useMutation(api.messages.send);
|
||||||
|
const editMessageMutation = useMutation(api.messages.edit);
|
||||||
|
const pinMessageMutation = useMutation(api.messages.pin);
|
||||||
|
const deleteMessageMutation = useMutation(api.messages.remove);
|
||||||
const addReaction = useMutation(api.reactions.add);
|
const addReaction = useMutation(api.reactions.add);
|
||||||
const removeReaction = useMutation(api.reactions.remove);
|
const removeReaction = useMutation(api.reactions.remove);
|
||||||
const startTypingMutation = useMutation(api.typing.startTyping);
|
const startTypingMutation = useMutation(api.typing.startTyping);
|
||||||
@@ -509,6 +567,20 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const decryptReplyContent = async (ciphertext, nonce) => {
|
||||||
|
try {
|
||||||
|
if (!ciphertext || !nonce || !channelKey) return null;
|
||||||
|
const TAG_LENGTH = 32;
|
||||||
|
const tag = ciphertext.slice(-TAG_LENGTH);
|
||||||
|
const content = ciphertext.slice(0, -TAG_LENGTH);
|
||||||
|
const decrypted = await window.cryptoAPI.decryptData(content, channelKey, nonce, tag);
|
||||||
|
if (decrypted.startsWith('{')) return '[Attachment]';
|
||||||
|
return decrypted.length > 100 ? decrypted.substring(0, 100) + '...' : decrypted;
|
||||||
|
} catch {
|
||||||
|
return '[Encrypted]';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const verifyMessage = async (msg) => {
|
const verifyMessage = async (msg) => {
|
||||||
if (!msg.signature || !msg.public_signing_key) return false;
|
if (!msg.signature || !msg.public_signing_key) return false;
|
||||||
try { return await window.cryptoAPI.verifySignature(msg.public_signing_key, msg.ciphertext, msg.signature); }
|
try { return await window.cryptoAPI.verifySignature(msg.public_signing_key, msg.ciphertext, msg.signature); }
|
||||||
@@ -525,27 +597,36 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const processMessages = async () => {
|
const processMessages = async () => {
|
||||||
// Decrypt only messages not already in cache
|
const needsDecryption = rawMessages.filter(msg => {
|
||||||
const needsDecryption = rawMessages.filter(msg => !cache.has(msg.id));
|
const cached = cache.get(msg.id);
|
||||||
|
if (!cached) return true;
|
||||||
|
// Re-decrypt if reply nonce is now available but reply wasn't decrypted
|
||||||
|
if (msg.replyToNonce && msg.replyToContent && cached.decryptedReply === null) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
if (needsDecryption.length > 0) {
|
if (needsDecryption.length > 0) {
|
||||||
await Promise.all(needsDecryption.map(async (msg) => {
|
await Promise.all(needsDecryption.map(async (msg) => {
|
||||||
const content = await decryptMessage(msg);
|
const content = await decryptMessage(msg);
|
||||||
const isVerified = await verifyMessage(msg);
|
const isVerified = await verifyMessage(msg);
|
||||||
|
let decryptedReply = null;
|
||||||
|
if (msg.replyToContent && msg.replyToNonce) {
|
||||||
|
decryptedReply = await decryptReplyContent(msg.replyToContent, msg.replyToNonce);
|
||||||
|
}
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
cache.set(msg.id, { content, isVerified });
|
cache.set(msg.id, { content, isVerified, decryptedReply });
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
// Build full chronological array (rawMessages is newest-first, reverse for display)
|
|
||||||
const processed = [...rawMessages].reverse().map(msg => {
|
const processed = [...rawMessages].reverse().map(msg => {
|
||||||
const cached = cache.get(msg.id);
|
const cached = cache.get(msg.id);
|
||||||
return {
|
return {
|
||||||
...msg,
|
...msg,
|
||||||
content: cached?.content ?? '[Decrypting...]',
|
content: cached?.content ?? '[Decrypting...]',
|
||||||
isVerified: cached?.isVerified ?? false,
|
isVerified: cached?.isVerified ?? false,
|
||||||
|
decryptedReply: cached?.decryptedReply ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
setDecryptedMessages(processed);
|
setDecryptedMessages(processed);
|
||||||
@@ -555,15 +636,19 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [rawMessages, channelKey]);
|
}, [rawMessages, channelKey]);
|
||||||
|
|
||||||
// Clear decryption cache and reset scroll state on channel/key change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
decryptionCacheRef.current.clear();
|
decryptionCacheRef.current.clear();
|
||||||
setDecryptedMessages([]);
|
setDecryptedMessages([]);
|
||||||
isInitialLoadRef.current = true;
|
isInitialLoadRef.current = true;
|
||||||
prevResultsLengthRef.current = 0;
|
prevResultsLengthRef.current = 0;
|
||||||
|
setReplyingTo(null);
|
||||||
|
setEditingMessage(null);
|
||||||
|
setMentionQuery(null);
|
||||||
|
onTogglePinned();
|
||||||
}, [channelId, channelKey]);
|
}, [channelId, channelKey]);
|
||||||
|
|
||||||
const typingUsers = typingData.filter(t => t.username !== username);
|
const typingUsers = typingData.filter(t => t.username !== username);
|
||||||
|
const filteredMentionMembers = mentionQuery !== null ? filterMembersForMention(members, mentionQuery) : [];
|
||||||
|
|
||||||
const scrollToBottom = useCallback((force = false) => {
|
const scrollToBottom = useCallback((force = false) => {
|
||||||
if (force) { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); return; }
|
if (force) { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); return; }
|
||||||
@@ -576,12 +661,10 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Scroll management via useLayoutEffect (fires before paint)
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const container = messagesContainerRef.current;
|
const container = messagesContainerRef.current;
|
||||||
if (!container || decryptedMessages.length === 0) return;
|
if (!container || decryptedMessages.length === 0) return;
|
||||||
|
|
||||||
// Initial load — instant scroll to bottom (no animation)
|
|
||||||
if (isInitialLoadRef.current) {
|
if (isInitialLoadRef.current) {
|
||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
isInitialLoadRef.current = false;
|
isInitialLoadRef.current = false;
|
||||||
@@ -589,7 +672,6 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load more (older messages prepended) — preserve scroll position
|
|
||||||
if (isLoadingMoreRef.current) {
|
if (isLoadingMoreRef.current) {
|
||||||
const newScrollHeight = container.scrollHeight;
|
const newScrollHeight = container.scrollHeight;
|
||||||
const heightDifference = newScrollHeight - prevScrollHeightRef.current;
|
const heightDifference = newScrollHeight - prevScrollHeightRef.current;
|
||||||
@@ -599,7 +681,6 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// User sent a message — force scroll to bottom
|
|
||||||
if (userSentMessageRef.current) {
|
if (userSentMessageRef.current) {
|
||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
userSentMessageRef.current = false;
|
userSentMessageRef.current = false;
|
||||||
@@ -607,7 +688,6 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Real-time new message — auto-scroll if near bottom
|
|
||||||
const currentLen = rawMessages?.length || 0;
|
const currentLen = rawMessages?.length || 0;
|
||||||
const prevLen = prevResultsLengthRef.current;
|
const prevLen = prevResultsLengthRef.current;
|
||||||
if (currentLen > prevLen && (currentLen - prevLen) <= 5) {
|
if (currentLen > prevLen && (currentLen - prevLen) <= 5) {
|
||||||
@@ -620,7 +700,6 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
prevResultsLengthRef.current = currentLen;
|
prevResultsLengthRef.current = currentLen;
|
||||||
}, [decryptedMessages, rawMessages?.length]);
|
}, [decryptedMessages, rawMessages?.length]);
|
||||||
|
|
||||||
// IntersectionObserver to trigger loadMore when scrolling near the top
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sentinel = topSentinelRef.current;
|
const sentinel = topSentinelRef.current;
|
||||||
const container = messagesContainerRef.current;
|
const container = messagesContainerRef.current;
|
||||||
@@ -692,6 +771,47 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
const newRange = document.createRange(); newRange.setStart(afterNode, 0); newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange);
|
const newRange = document.createRange(); newRange.setStart(afterNode, 0); newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkMentionTrigger = () => {
|
||||||
|
if (!inputDivRef.current) return;
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection.rangeCount) return;
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const node = range.startContainer;
|
||||||
|
if (node.nodeType !== Node.TEXT_NODE) { setMentionQuery(null); return; }
|
||||||
|
const content = node.textContent.substring(0, range.startOffset);
|
||||||
|
const match = content.match(/(?:^|\s)@(\w*)$/);
|
||||||
|
if (match) {
|
||||||
|
setMentionQuery(match[1]);
|
||||||
|
setMentionIndex(0);
|
||||||
|
} else {
|
||||||
|
setMentionQuery(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertMention = (member) => {
|
||||||
|
if (!inputDivRef.current) return;
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection.rangeCount) return;
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const node = range.startContainer;
|
||||||
|
if (node.nodeType !== Node.TEXT_NODE) return;
|
||||||
|
const content = node.textContent.substring(0, range.startOffset);
|
||||||
|
const match = content.match(/(?:^|\s)@(\w*)$/);
|
||||||
|
if (!match) return;
|
||||||
|
const matchStart = match.index + (match[0].startsWith(' ') ? 1 : 0);
|
||||||
|
const before = node.textContent.substring(0, matchStart);
|
||||||
|
const after = node.textContent.substring(range.startOffset);
|
||||||
|
node.textContent = before + '@' + member.username + ' ' + after;
|
||||||
|
const newOffset = before.length + 1 + member.username.length + 1;
|
||||||
|
const newRange = document.createRange();
|
||||||
|
newRange.setStart(node, newOffset);
|
||||||
|
newRange.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(newRange);
|
||||||
|
setMentionQuery(null);
|
||||||
|
setInput(inputDivRef.current.textContent);
|
||||||
|
};
|
||||||
|
|
||||||
const processFile = (file) => { setPendingFiles(prev => [...prev, file]); };
|
const processFile = (file) => { setPendingFiles(prev => [...prev, file]); };
|
||||||
const handleFileSelect = (e) => { if (e.target.files && e.target.files.length > 0) Array.from(e.target.files).forEach(processFile); };
|
const handleFileSelect = (e) => { if (e.target.files && e.target.files.length > 0) Array.from(e.target.files).forEach(processFile); };
|
||||||
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); if (!isDragging) setIsDragging(true); };
|
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); if (!isDragging) setIsDragging(true); };
|
||||||
@@ -726,7 +846,7 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
await sendMessage(JSON.stringify(metadata));
|
await sendMessage(JSON.stringify(metadata));
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendMessage = async (contentString) => {
|
const sendMessage = async (contentString, replyToId) => {
|
||||||
try {
|
try {
|
||||||
if (!channelKey) { alert("Cannot send: Missing Encryption Key"); return; }
|
if (!channelKey) { alert("Cannot send: Missing Encryption Key"); return; }
|
||||||
const { content: encryptedContent, iv, tag } = await window.cryptoAPI.encryptData(contentString, channelKey);
|
const { content: encryptedContent, iv, tag } = await window.cryptoAPI.encryptData(contentString, channelKey);
|
||||||
@@ -735,14 +855,17 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
const signingKey = sessionStorage.getItem('signingKey');
|
const signingKey = sessionStorage.getItem('signingKey');
|
||||||
if (!senderId || !signingKey) return;
|
if (!senderId || !signingKey) return;
|
||||||
|
|
||||||
await sendMessageMutation({
|
const args = {
|
||||||
channelId,
|
channelId,
|
||||||
senderId,
|
senderId,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
nonce: iv,
|
nonce: iv,
|
||||||
signature: await window.cryptoAPI.signMessage(signingKey, ciphertext),
|
signature: await window.cryptoAPI.signMessage(signingKey, ciphertext),
|
||||||
keyVersion: 1
|
keyVersion: 1
|
||||||
});
|
};
|
||||||
|
if (replyToId) args.replyTo = replyToId;
|
||||||
|
|
||||||
|
await sendMessageMutation(args);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Send error:', err);
|
console.error('Send error:', err);
|
||||||
}
|
}
|
||||||
@@ -772,15 +895,18 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
userSentMessageRef.current = true;
|
userSentMessageRef.current = true;
|
||||||
|
const replyId = replyingTo?.messageId;
|
||||||
try {
|
try {
|
||||||
for (const file of pendingFiles) await uploadAndSendFile(file);
|
for (const file of pendingFiles) await uploadAndSendFile(file);
|
||||||
setPendingFiles([]);
|
setPendingFiles([]);
|
||||||
if (messageContent) {
|
if (messageContent) {
|
||||||
await sendMessage(messageContent);
|
await sendMessage(messageContent, replyId);
|
||||||
if (inputDivRef.current) inputDivRef.current.innerHTML = '';
|
if (inputDivRef.current) inputDivRef.current.innerHTML = '';
|
||||||
setInput(''); setHasImages(false);
|
setInput(''); setHasImages(false);
|
||||||
clearTypingState();
|
clearTypingState();
|
||||||
}
|
}
|
||||||
|
setReplyingTo(null);
|
||||||
|
setMentionQuery(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error sending message/files:", err);
|
console.error("Error sending message/files:", err);
|
||||||
alert("Failed to send message/files");
|
alert("Failed to send message/files");
|
||||||
@@ -790,8 +916,38 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditSave = async () => {
|
||||||
|
if (!editingMessage || !editInput.trim()) {
|
||||||
|
setEditingMessage(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { content: encryptedContent, iv, tag } = await window.cryptoAPI.encryptData(editInput, channelKey);
|
||||||
|
const ciphertext = encryptedContent + tag;
|
||||||
|
const signingKey = sessionStorage.getItem('signingKey');
|
||||||
|
await editMessageMutation({
|
||||||
|
id: editingMessage.id,
|
||||||
|
ciphertext,
|
||||||
|
nonce: iv,
|
||||||
|
signature: await window.cryptoAPI.signMessage(signingKey, ciphertext),
|
||||||
|
});
|
||||||
|
decryptionCacheRef.current.delete(editingMessage.id);
|
||||||
|
setEditingMessage(null);
|
||||||
|
setEditInput('');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Edit error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
|
if (mentionQuery !== null && filteredMentionMembers.length > 0) {
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => (i + 1) % filteredMentionMembers.length); return; }
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(i => (i - 1 + filteredMentionMembers.length) % filteredMentionMembers.length); return; }
|
||||||
|
if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') { e.preventDefault(); insertMention(filteredMentionMembers[mentionIndex]); return; }
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); setMentionQuery(null); return; }
|
||||||
|
}
|
||||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(e); }
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(e); }
|
||||||
|
if (e.key === 'Escape' && replyingTo) { setReplyingTo(null); return; }
|
||||||
if (e.key === 'Backspace' && inputDivRef.current) {
|
if (e.key === 'Backspace' && inputDivRef.current) {
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
if (sel.rangeCount > 0 && sel.isCollapsed) {
|
if (sel.rangeCount > 0 && sel.isCollapsed) {
|
||||||
@@ -809,6 +965,11 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleEditSave(); }
|
||||||
|
if (e.key === 'Escape') { setEditingMessage(null); setEditInput(''); }
|
||||||
|
};
|
||||||
|
|
||||||
const handleReactionClick = async (messageId, emoji, hasMyReaction) => {
|
const handleReactionClick = async (messageId, emoji, hasMyReaction) => {
|
||||||
if (!currentUserId) return;
|
if (!currentUserId) return;
|
||||||
if (hasMyReaction) {
|
if (hasMyReaction) {
|
||||||
@@ -826,7 +987,53 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleContextInteract = (action, messageId) => {
|
||||||
|
const msg = decryptedMessages.find(m => m.id === messageId);
|
||||||
|
if (!msg) return;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'reply':
|
||||||
|
setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) });
|
||||||
|
break;
|
||||||
|
case 'edit':
|
||||||
|
setEditingMessage({ id: msg.id, content: msg.content });
|
||||||
|
setEditInput(msg.content);
|
||||||
|
break;
|
||||||
|
case 'pin':
|
||||||
|
pinMessageMutation({ id: msg.id, pinned: !msg.pinned });
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
deleteMessageMutation({ id: msg.id });
|
||||||
|
break;
|
||||||
|
case 'reaction':
|
||||||
|
addReaction({ messageId: msg.id, userId: currentUserId, emoji: 'heart' });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setContextMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToMessage = (messageId) => {
|
||||||
|
const el = document.getElementById(`msg-${messageId}`);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
el.classList.add('message-highlight');
|
||||||
|
setTimeout(() => el.classList.remove('message-highlight'), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderMessageContent = (msg) => {
|
const renderMessageContent = (msg) => {
|
||||||
|
const systemMsg = parseSystemMessage(msg.content);
|
||||||
|
if (systemMsg) {
|
||||||
|
return (
|
||||||
|
<div className="system-message">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="#3ba55c" style={{ marginRight: '8px', flexShrink: 0 }}>
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontStyle: 'italic', color: 'var(--header-secondary)' }}>{systemMsg.text || 'System event'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const attachmentMetadata = parseAttachment(msg.content);
|
const attachmentMetadata = parseAttachment(msg.content);
|
||||||
if (attachmentMetadata) {
|
if (attachmentMetadata) {
|
||||||
return <Attachment metadata={attachmentMetadata} onLoad={scrollToBottom} onImageClick={setZoomedImage} />;
|
return <Attachment metadata={attachmentMetadata} onLoad={scrollToBottom} onImageClick={setZoomedImage} />;
|
||||||
@@ -854,18 +1061,30 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}>
|
||||||
{Object.entries(msg.reactions).map(([emojiName, data]) => (
|
{Object.entries(msg.reactions).map(([emojiName, data]) => (
|
||||||
<div key={emojiName} onClick={() => handleReactionClick(msg.id, emojiName, data.me)} style={{ display: 'flex', alignItems: 'center', backgroundColor: data.me ? 'rgba(88, 101, 242, 0.15)' : '#2f3136', border: data.me ? '1px solid #5865F2' : '1px solid transparent', borderRadius: '8px', padding: '2px 6px', cursor: 'pointer', gap: '4px' }}>
|
<div key={emojiName} onClick={() => handleReactionClick(msg.id, emojiName, data.me)} style={{ display: 'flex', alignItems: 'center', backgroundColor: data.me ? 'rgba(88, 101, 242, 0.15)' : 'var(--embed-background)', border: data.me ? '1px solid var(--brand-experiment)' : '1px solid transparent', borderRadius: '8px', padding: '2px 6px', cursor: 'pointer', gap: '4px' }}>
|
||||||
<ColoredIcon src={getReactionIcon(emojiName)} size="16px" color={data.me ? null : '#b9bbbe'} />
|
<ColoredIcon src={getReactionIcon(emojiName)} size="16px" color={data.me ? null : 'var(--header-secondary)'} />
|
||||||
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : '#b9bbbe', fontWeight: 600 }}>{data.count}</span>
|
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : 'var(--header-secondary)', fontWeight: 600 }}>{data.count}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isDM = channelType === 'dm';
|
||||||
|
const placeholderText = isDM ? `Message @${channelName || 'user'}` : `Message #${channelName || channelId}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-area" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} style={{ position: 'relative' }}>
|
<div className="chat-area" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} style={{ position: 'relative' }}>
|
||||||
{isDragging && <DragOverlay />}
|
{isDragging && <DragOverlay />}
|
||||||
|
|
||||||
|
<PinnedMessagesPanel
|
||||||
|
channelId={channelId}
|
||||||
|
visible={showPinned}
|
||||||
|
onClose={onTogglePinned}
|
||||||
|
channelKey={channelKey}
|
||||||
|
onJumpToMessage={scrollToMessage}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="messages-list" ref={messagesContainerRef}>
|
<div className="messages-list" ref={messagesContainerRef}>
|
||||||
<div className="messages-content-wrapper">
|
<div className="messages-content-wrapper">
|
||||||
<div ref={topSentinelRef} style={{ height: '1px', width: '100%' }} />
|
<div ref={topSentinelRef} style={{ height: '1px', width: '100%' }} />
|
||||||
@@ -876,9 +1095,16 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
)}
|
)}
|
||||||
{status === 'Exhausted' && decryptedMessages.length > 0 && (
|
{status === 'Exhausted' && decryptedMessages.length > 0 && (
|
||||||
<div className="channel-beginning">
|
<div className="channel-beginning">
|
||||||
<div className="channel-beginning-icon">#</div>
|
<div className="channel-beginning-icon">{isDM ? '@' : '#'}</div>
|
||||||
<h1 className="channel-beginning-title">Welcome to #{channelName}</h1>
|
<h1 className="channel-beginning-title">
|
||||||
<p className="channel-beginning-subtitle">This is the start of the #{channelName} channel.</p>
|
{isDM ? `${channelName}` : `Welcome to #${channelName}`}
|
||||||
|
</h1>
|
||||||
|
<p className="channel-beginning-subtitle">
|
||||||
|
{isDM
|
||||||
|
? `This is the beginning of your direct message history with ${channelName}.`
|
||||||
|
: `This is the start of the #${channelName} channel.`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{status === 'LoadingFirstPage' && (
|
{status === 'LoadingFirstPage' && (
|
||||||
@@ -892,18 +1118,39 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
const isMentioned = msg.content && msg.content.includes(`@${username}`);
|
const isMentioned = msg.content && msg.content.includes(`@${username}`);
|
||||||
const userColor = getUserColor(msg.username || 'Unknown');
|
const userColor = getUserColor(msg.username || 'Unknown');
|
||||||
const isOwner = msg.username === username;
|
const isOwner = msg.username === username;
|
||||||
|
const isEditing = editingMessage?.id === msg.id;
|
||||||
|
|
||||||
const prevMsg = idx > 0 ? decryptedMessages[idx - 1] : null;
|
const prevMsg = idx > 0 ? decryptedMessages[idx - 1] : null;
|
||||||
const isGrouped = prevMsg
|
const isGrouped = prevMsg
|
||||||
&& prevMsg.username === msg.username
|
&& prevMsg.username === msg.username
|
||||||
&& !isNewDay(currentDate, previousDate)
|
&& !isNewDay(currentDate, previousDate)
|
||||||
&& (currentDate - new Date(prevMsg.created_at)) < 60000;
|
&& (currentDate - new Date(prevMsg.created_at)) < 60000
|
||||||
|
&& !msg.replyToId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={msg.id || idx}>
|
<React.Fragment key={msg.id || idx}>
|
||||||
{isNewDay(currentDate, previousDate) && <div className="date-divider"><span>{currentDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}</span></div>}
|
{isNewDay(currentDate, previousDate) && <div className="date-divider"><span>{currentDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}</span></div>}
|
||||||
<div className={`message-item${isGrouped ? ' message-grouped' : ''}`} style={isMentioned ? { background: 'color-mix(in oklab,hsl(36.894 calc(1*100%) 31.569% /0.0784313725490196) 100%,hsl(0 0% 0% /0.0784313725490196) 0%)', position: 'relative' } : { position: 'relative' }} onMouseEnter={() => setHoveredMessageId(msg.id)} onMouseLeave={() => setHoveredMessageId(null)} onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner }); }}>
|
<div
|
||||||
|
id={`msg-${msg.id}`}
|
||||||
|
className={`message-item${isGrouped ? ' message-grouped' : ''}`}
|
||||||
|
style={isMentioned ? { background: 'color-mix(in oklab,hsl(36.894 calc(1*100%) 31.569% /0.0784313725490196) 100%,hsl(0 0% 0% /0.0784313725490196) 0%)', position: 'relative' } : { position: 'relative' }}
|
||||||
|
onMouseEnter={() => setHoveredMessageId(msg.id)}
|
||||||
|
onMouseLeave={() => setHoveredMessageId(null)}
|
||||||
|
onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner }); }}
|
||||||
|
>
|
||||||
{isMentioned && <div style={{ background: 'color-mix(in oklab, hsl(34 calc(1*50.847%) 53.725% /1) 100%, #000 0%)', bottom: 0, display: 'block', left: 0, pointerEvents: 'none', position: 'absolute', top: 0, width: '2px' }} />}
|
{isMentioned && <div style={{ background: 'color-mix(in oklab, hsl(34 calc(1*50.847%) 53.725% /1) 100%, #000 0%)', bottom: 0, display: 'block', left: 0, pointerEvents: 'none', position: 'absolute', top: 0, width: '2px' }} />}
|
||||||
|
|
||||||
|
{msg.replyToId && msg.replyToUsername && (
|
||||||
|
<div className="message-reply-context" onClick={() => scrollToMessage(msg.replyToId)}>
|
||||||
|
<div className="reply-spine" />
|
||||||
|
<Avatar username={msg.replyToUsername} avatarUrl={msg.replyToAvatarUrl} size={16} className="reply-avatar" />
|
||||||
|
<span className="reply-author" style={{ color: getUserColor(msg.replyToUsername) }}>
|
||||||
|
@{msg.replyToUsername}
|
||||||
|
</span>
|
||||||
|
<span className="reply-text">{msg.decryptedReply || '[Encrypted]'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isGrouped ? (
|
{isGrouped ? (
|
||||||
<div className="message-avatar-wrapper grouped-timestamp-wrapper">
|
<div className="message-avatar-wrapper grouped-timestamp-wrapper">
|
||||||
<span className="grouped-timestamp">
|
<span className="grouped-timestamp">
|
||||||
@@ -912,29 +1159,56 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="message-avatar-wrapper">
|
<div className="message-avatar-wrapper">
|
||||||
<div className="message-avatar" style={{ backgroundColor: userColor }}>
|
<Avatar
|
||||||
{(msg.username || '?').substring(0, 1).toUpperCase()}
|
username={msg.username}
|
||||||
</div>
|
avatarUrl={msg.avatarUrl}
|
||||||
|
size={40}
|
||||||
|
className="message-avatar"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={(e) => setProfilePopup({ userId: msg.sender_id, username: msg.username, avatarUrl: msg.avatarUrl, position: { x: e.clientX, y: e.clientY } })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="message-body">
|
<div className="message-body">
|
||||||
{!isGrouped && (
|
{!isGrouped && (
|
||||||
<div className="message-header">
|
<div className="message-header">
|
||||||
<span className="username" style={{ color: userColor }}>{msg.username || 'Unknown'}</span>
|
<span
|
||||||
|
className="username"
|
||||||
|
style={{ color: userColor, cursor: 'pointer' }}
|
||||||
|
onClick={(e) => setProfilePopup({ userId: msg.sender_id, username: msg.username, avatarUrl: msg.avatarUrl, position: { x: e.clientX, y: e.clientY } })}
|
||||||
|
>
|
||||||
|
{msg.username || 'Unknown'}
|
||||||
|
</span>
|
||||||
{!msg.isVerified && <span className="verification-failed" title="Signature Verification Failed!"><svg aria-hidden="true" role="img" width="16" height="16" viewBox="0 0 24 24" fill="var(--danger)"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></svg></span>}
|
{!msg.isVerified && <span className="verification-failed" title="Signature Verification Failed!"><svg aria-hidden="true" role="img" width="16" height="16" viewBox="0 0 24 24" fill="var(--danger)"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></svg></span>}
|
||||||
<span className="timestamp">{currentDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span>
|
<span className="timestamp">{currentDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<div className="message-content">
|
{isEditing ? (
|
||||||
{renderMessageContent(msg)}
|
<div className="message-editing">
|
||||||
{renderReactions(msg)}
|
<textarea
|
||||||
</div>
|
className="message-edit-textarea"
|
||||||
{hoveredMessageId === msg.id && (
|
value={editInput}
|
||||||
|
onChange={(e) => setEditInput(e.target.value)}
|
||||||
|
onKeyDown={handleEditKeyDown}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="message-edit-hint">
|
||||||
|
escape to <span onClick={() => { setEditingMessage(null); setEditInput(''); }}>cancel</span> · enter to <span onClick={handleEditSave}>save</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="message-content">
|
||||||
|
{renderMessageContent(msg)}
|
||||||
|
{msg.editedAt && <span className="edited-indicator">(edited)</span>}
|
||||||
|
{renderReactions(msg)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hoveredMessageId === msg.id && !isEditing && (
|
||||||
<MessageToolbar isOwner={isOwner}
|
<MessageToolbar isOwner={isOwner}
|
||||||
onAddReaction={(emoji) => { addReaction({ messageId: msg.id, userId: currentUserId, emoji: emoji || 'heart' }); }}
|
onAddReaction={(emoji) => { addReaction({ messageId: msg.id, userId: currentUserId, emoji: emoji || 'heart' }); }}
|
||||||
onEdit={() => console.log('Edit', msg.id)}
|
onEdit={() => { setEditingMessage({ id: msg.id, content: msg.content }); setEditInput(msg.content); }}
|
||||||
onReply={() => console.log('Reply', msg.id)}
|
onReply={() => setReplyingTo({ messageId: msg.id, username: msg.username, content: msg.content?.substring(0, 100) })}
|
||||||
onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner }); }}
|
onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner }); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -947,17 +1221,36 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{contextMenu && <MessageContextMenu x={contextMenu.x} y={contextMenu.y} isOwner={contextMenu.isOwner} onClose={() => setContextMenu(null)} onInteract={(action) => { console.log('Context Action:', action, contextMenu.messageId); setContextMenu(null); }} />}
|
{contextMenu && <MessageContextMenu x={contextMenu.x} y={contextMenu.y} isOwner={contextMenu.isOwner} onClose={() => setContextMenu(null)} onInteract={(action) => handleContextInteract(action, contextMenu.messageId)} />}
|
||||||
|
|
||||||
<form className="chat-input-form" onSubmit={handleSend} style={{ position: 'relative' }}>
|
<form className="chat-input-form" onSubmit={handleSend} style={{ position: 'relative' }}>
|
||||||
|
{mentionQuery !== null && filteredMentionMembers.length > 0 && (
|
||||||
|
<MentionMenu
|
||||||
|
members={filteredMentionMembers}
|
||||||
|
selectedIndex={mentionIndex}
|
||||||
|
onSelect={insertMention}
|
||||||
|
onHover={setMentionIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{typingUsers.length > 0 && (
|
{typingUsers.length > 0 && (
|
||||||
<div style={{ position: 'absolute', top: '-24px', left: '0', padding: '0 8px', display: 'flex', alignItems: 'center', gap: '6px', color: '#dbdee1', fontSize: '12px', fontWeight: 'bold', pointerEvents: 'none' }}>
|
<div style={{ position: 'absolute', top: '-24px', left: '0', padding: '0 8px', display: 'flex', alignItems: 'center', gap: '6px', color: '#dbdee1', fontSize: '12px', fontWeight: 'bold', pointerEvents: 'none' }}>
|
||||||
<ColoredIcon src={TypingIcon} size="24px" color="#dbdee1" />
|
<ColoredIcon src={TypingIcon} size="24px" color="#dbdee1" />
|
||||||
<span>{typingUsers.map(t => t.username).join(', ')} is typing...</span>
|
<span>{typingUsers.map(t => t.username).join(', ')} is typing...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{replyingTo && (
|
||||||
|
<div className="reply-preview-bar">
|
||||||
|
<div className="reply-preview-content">
|
||||||
|
Replying to <strong>{replyingTo.username}</strong>
|
||||||
|
<span className="reply-preview-text">{replyingTo.content}</span>
|
||||||
|
</div>
|
||||||
|
<button className="reply-preview-close" onClick={() => setReplyingTo(null)}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{pendingFiles.length > 0 && (
|
{pendingFiles.length > 0 && (
|
||||||
<div style={{ display: 'flex', padding: '10px 16px 0', overflowX: 'auto', backgroundColor: 'color-mix(in oklab, hsl(240 calc(1*5.882%) 13.333% /1) 100%, #000 0%)', borderRadius: '8px 8px 0 0' }}>
|
<div style={{ display: 'flex', padding: '10px 16px 0', overflowX: 'auto', backgroundColor: 'var(--channeltextarea-background)', borderRadius: '8px 8px 0 0' }}>
|
||||||
{pendingFiles.map((file, idx) => <PendingFilePreview key={idx} file={file} onRemove={(f) => setPendingFiles(prev => prev.filter(item => item !== f))} />)}
|
{pendingFiles.map((file, idx) => <PendingFilePreview key={idx} file={file} onRemove={(f) => setPendingFiles(prev => prev.filter(item => item !== f))} />)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -971,11 +1264,20 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
onMouseUp={saveSelection}
|
onMouseUp={saveSelection}
|
||||||
onKeyUp={saveSelection}
|
onKeyUp={saveSelection}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
setInput(e.currentTarget.textContent);
|
const textContent = e.currentTarget.textContent;
|
||||||
|
setInput(textContent);
|
||||||
setHasImages(e.currentTarget.querySelectorAll('img').length > 0);
|
setHasImages(e.currentTarget.querySelectorAll('img').length > 0);
|
||||||
const text = e.currentTarget.innerText;
|
|
||||||
setIsMultiline(text.includes('\n') || e.currentTarget.scrollHeight > 50);
|
// Clean up browser artifacts (residual <br>) when content is fully erased
|
||||||
|
if (!textContent && !e.currentTarget.querySelectorAll('img').length) {
|
||||||
|
e.currentTarget.innerHTML = '';
|
||||||
|
setIsMultiline(false);
|
||||||
|
} else {
|
||||||
|
const text = e.currentTarget.innerText;
|
||||||
|
setIsMultiline(text.includes('\n') || e.currentTarget.scrollHeight > 50);
|
||||||
|
}
|
||||||
checkTypedEmoji();
|
checkTypedEmoji();
|
||||||
|
checkMentionTrigger();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastTypingEmitRef.current > 2000 && currentUserId && channelId) {
|
if (now - lastTypingEmitRef.current > 2000 && currentUserId && channelId) {
|
||||||
startTypingMutation({ channelId, userId: currentUserId, username }).catch(() => {});
|
startTypingMutation({ channelId, userId: currentUserId, username }).catch(() => {});
|
||||||
@@ -988,17 +1290,23 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
style={{ flex: 1, backgroundColor: 'transparent', border: 'none', color: '#dcddde', fontSize: '16px', marginTop: '20px', marginLeft: '6px', minHeight: '44px', maxHeight: '200px', overflowY: 'auto', outline: 'none', whiteSpace: 'pre-wrap', wordBreak: 'break-word', lineHeight: '1.375rem', marginBottom: isMultiline ? '20px' : '0px' }}
|
style={{ flex: 1, backgroundColor: 'transparent', border: 'none', color: 'var(--text-normal)', fontSize: '16px', marginTop: '20px', marginLeft: '6px', minHeight: '44px', maxHeight: '200px', overflowY: 'auto', outline: 'none', whiteSpace: 'pre-wrap', wordBreak: 'break-word', lineHeight: '1.375rem', marginBottom: isMultiline ? '20px' : '0px' }}
|
||||||
/>
|
/>
|
||||||
{!input && !hasImages && <div style={{ position: 'absolute', left: '70px', color: '#72767d', pointerEvents: 'none', userSelect: 'none' }}>Message #{channelName || channelId}</div>}
|
{!input && !hasImages && <div style={{ position: 'absolute', left: '70px', color: 'var(--text-muted)', pointerEvents: 'none', userSelect: 'none' }}>{placeholderText}</div>}
|
||||||
<div className="chat-input-icons" style={{ position: 'relative' }}>
|
<div className="chat-input-icons" style={{ position: 'relative' }}>
|
||||||
<button type="button" className="chat-input-icon-btn" title="GIF" onClick={(e) => { e.stopPropagation(); togglePicker('GIFs'); }}>
|
<Tooltip text="GIF" position="top">
|
||||||
<ColoredIcon src={GifIcon} color={pickerTab === 'GIFs' ? '#5865f2' : ICON_COLOR_DEFAULT} size="24px" />
|
<button type="button" className="chat-input-icon-btn" onClick={(e) => { e.stopPropagation(); togglePicker('GIFs'); }}>
|
||||||
</button>
|
<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 && (
|
{showGifPicker && (
|
||||||
<GifPicker onSelect={(data) => { if (typeof data === 'string') { sendMessage(data); setPickerTab(null); } else { insertEmoji(data); setPickerTab(null); } }} onClose={() => setPickerTab(null)} currentTab={pickerTab} onTabChange={setPickerTab} />
|
<GifPicker onSelect={(data) => { if (typeof data === 'string') { sendMessage(data); setPickerTab(null); } else { insertEmoji(data); setPickerTab(null); } }} onClose={() => setPickerTab(null)} currentTab={pickerTab} onTabChange={setPickerTab} />
|
||||||
)}
|
)}
|
||||||
<button type="button" className="chat-input-icon-btn" title="Sticker"><ColoredIcon src={StickerIcon} color={ICON_COLOR_DEFAULT} size="24px" /></button>
|
|
||||||
<EmojiButton active={pickerTab === 'Emoji'} onClick={() => togglePicker('Emoji')} />
|
<EmojiButton active={pickerTab === 'Emoji'} onClick={() => togglePicker('Emoji')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1008,6 +1316,17 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
<img src={zoomedImage} alt="Zoomed" style={{ maxWidth: '90%', maxHeight: '90%', boxShadow: '0 8px 16px rgba(0,0,0,0.5)', borderRadius: '4px', cursor: 'default' }} onClick={(e) => e.stopPropagation()} />
|
<img src={zoomedImage} alt="Zoomed" style={{ maxWidth: '90%', maxHeight: '90%', boxShadow: '0 8px 16px rgba(0,0,0,0.5)', borderRadius: '4px', cursor: 'default' }} onClick={(e) => e.stopPropagation()} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{profilePopup && (
|
||||||
|
<UserProfilePopup
|
||||||
|
userId={profilePopup.userId}
|
||||||
|
username={profilePopup.username}
|
||||||
|
avatarUrl={profilePopup.avatarUrl}
|
||||||
|
status="online"
|
||||||
|
position={profilePopup.position}
|
||||||
|
onClose={() => setProfilePopup(null)}
|
||||||
|
onSendMessage={onOpenDM}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
74
Frontend/Electron/src/components/ChatHeader.jsx
Normal file
74
Frontend/Electron/src/components/ChatHeader.jsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import Tooltip from './Tooltip';
|
||||||
|
|
||||||
|
const ChatHeader = ({ channelName, channelType, channelTopic, onToggleMembers, showMembers, onTogglePinned, serverName }) => {
|
||||||
|
const [searchFocused, setSearchFocused] = useState(false);
|
||||||
|
|
||||||
|
const isDM = channelType === 'dm';
|
||||||
|
const searchPlaceholder = isDM ? 'Search' : `Search ${serverName || 'Server'}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-header">
|
||||||
|
<div className="chat-header-left">
|
||||||
|
<span className="chat-header-icon">{isDM ? '@' : '#'}</span>
|
||||||
|
<span className="chat-header-name">{channelName}</span>
|
||||||
|
{channelTopic && !isDM && (
|
||||||
|
<>
|
||||||
|
<div className="chat-header-divider" />
|
||||||
|
<span className="chat-header-topic" title={channelTopic}>{channelTopic}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isDM && <span className="chat-header-status-text"></span>}
|
||||||
|
</div>
|
||||||
|
<div className="chat-header-right">
|
||||||
|
{!isDM && (
|
||||||
|
<Tooltip text="Threads" position="bottom">
|
||||||
|
<button className="chat-header-btn">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M5.43309 21C5.35842 21 5.30189 20.9325 5.31494 20.859L5.99991 17H2.14274C2.06819 17 2.01168 16.9327 2.02453 16.8593L2.33253 15.0993C2.34258 15.0419 2.39244 15 2.45074 15H6.34991L7.14991 10.5H3.29274C3.21819 10.5 3.16168 10.4327 3.17453 10.3593L3.48253 8.59926C3.49258 8.54185 3.54244 8.5 3.60074 8.5H7.49991L8.25674 4.49395C8.26688 4.43665 8.31672 4.395 8.37491 4.395H10.1919C10.2666 4.395 10.3231 4.4625 10.3101 4.536L9.59991 8.5H14.0999L14.8568 4.49395C14.8669 4.43665 14.9167 4.395 14.9749 4.395H16.7919C16.8666 4.395 16.9231 4.4625 16.9101 4.536L16.1999 8.5H20.0571C20.1316 8.5 20.1881 8.56734 20.1753 8.64074L19.8673 10.4007C19.8572 10.4581 19.8074 10.5 19.7491 10.5H15.8499L15.0499 15H18.9071C18.9816 15 19.0381 15.0673 19.0253 15.1407L18.7173 16.9007C18.7072 16.9581 18.6574 17 18.5991 17H14.6999L13.9431 21.006C13.9329 21.0634 13.8831 21.105 13.8249 21.105H12.0079C11.9332 21.105 11.8767 21.0375 11.8897 20.964L12.5999 17H8.09991L7.34309 21.006C7.33295 21.0634 7.28311 21.105 7.22491 21.105H5.43309V21ZM8.44991 15H12.9499L13.7499 10.5H9.24991L8.44991 15Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip text="Pinned Messages" position="bottom">
|
||||||
|
<button className="chat-header-btn" onClick={onTogglePinned}>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M19.3 5.3a1 1 0 00-1.4-1.4L14.6 7.2l-1.5-.8a2 2 0 00-2.2.2L8.5 9a1 1 0 000 1.5l1.8 1.8-4.6 4.6a1 1 0 001.4 1.4l4.6-4.6 1.8 1.8a1 1 0 001.5 0l2.4-2.4a2 2 0 00.2-2.2l-.8-1.5 3.3-3.3z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
{!isDM && (
|
||||||
|
<Tooltip text={showMembers ? "Hide Members" : "Show Members"} position="bottom">
|
||||||
|
<button
|
||||||
|
className={`chat-header-btn ${showMembers ? 'active' : ''}`}
|
||||||
|
onClick={onToggleMembers}
|
||||||
|
>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M14 8.006c0 2.206-1.346 4-3 4s-3-1.794-3-4 1.346-4 3-4 3 1.794 3 4zm-6.154 6.63c.896-.758 2.157-1.236 3.654-1.236s2.758.478 3.654 1.236c.898.76 1.346 1.773 1.346 2.87v.5h-10v-.5c0-1.097.448-2.11 1.346-2.87z" />
|
||||||
|
<path d="M20 10.006c0 1.655-1.01 3-2.25 3s-2.25-1.345-2.25-3S16.51 7.006 17.75 7.006 20 8.351 20 10.006zm-1.146 5.282c.674-.57 1.622-.93 2.646-.93.906 0 1.754.282 2.417.781.663.5 1.083 1.178 1.083 1.867V17.5h-4.6c-.173-.652-.52-1.262-1.032-1.794a5.86 5.86 0 00-.514-.418z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip text="Notification Settings" position="bottom">
|
||||||
|
<button className="chat-header-btn">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18 9V14C18 15.657 19.344 17 21 17V18H3V17C4.656 17 6 15.657 6 14V9C6 5.686 8.686 3 12 3C15.314 3 18 5.686 18 9ZM11.9999 22C10.5239 22 9.24993 20.955 8.99993 19.5H14.9999C14.7499 20.955 13.4759 22 11.9999 22Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<div className="chat-header-search-wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className={`chat-header-search ${searchFocused ? 'focused' : ''}`}
|
||||||
|
onFocus={() => setSearchFocused(true)}
|
||||||
|
onBlur={() => setSearchFocused(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatHeader;
|
||||||
@@ -1,29 +1,48 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useConvex } from 'convex/react';
|
import { useConvex } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
|
import Tooltip from './Tooltip';
|
||||||
|
import Avatar from './Avatar';
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
online: '#3ba55c',
|
||||||
|
idle: '#faa61a',
|
||||||
|
dnd: '#ed4245',
|
||||||
|
invisible: '#747f8d',
|
||||||
|
offline: '#747f8d',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
online: 'Online',
|
||||||
|
idle: 'Idle',
|
||||||
|
dnd: 'Do Not Disturb',
|
||||||
|
invisible: 'Offline',
|
||||||
|
offline: 'Offline',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserColor = (username) => {
|
||||||
|
if (!username) return '#5865F2';
|
||||||
|
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < username.length; i++) {
|
||||||
|
hash = username.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return colors[Math.abs(hash) % colors.length];
|
||||||
|
};
|
||||||
|
|
||||||
const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
|
const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
|
||||||
const [showUserPicker, setShowUserPicker] = useState(false);
|
const [showUserPicker, setShowUserPicker] = useState(false);
|
||||||
const [allUsers, setAllUsers] = useState([]);
|
const [allUsers, setAllUsers] = useState([]);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchFocused, setSearchFocused] = useState(false);
|
||||||
const searchRef = useRef(null);
|
const searchRef = useRef(null);
|
||||||
|
const searchInputRef = useRef(null);
|
||||||
|
|
||||||
const convex = useConvex();
|
const convex = useConvex();
|
||||||
|
|
||||||
const getUserColor = (username) => {
|
|
||||||
if (!username) return '#5865F2';
|
|
||||||
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < username.length; i++) {
|
|
||||||
hash = username.charCodeAt(i) + ((hash << 5) - hash);
|
|
||||||
}
|
|
||||||
return colors[Math.abs(hash) % colors.length];
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenUserPicker = async () => {
|
const handleOpenUserPicker = async () => {
|
||||||
setShowUserPicker(true);
|
setShowUserPicker(true);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
// Fetch all users via Convex query
|
|
||||||
try {
|
try {
|
||||||
const data = await convex.query(api.auth.getPublicKeys, {});
|
const data = await convex.query(api.auth.getPublicKeys, {});
|
||||||
const myId = localStorage.getItem('userId');
|
const myId = localStorage.getItem('userId');
|
||||||
@@ -33,6 +52,19 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSearchFocus = async () => {
|
||||||
|
setSearchFocused(true);
|
||||||
|
if (allUsers.length === 0) {
|
||||||
|
try {
|
||||||
|
const data = await convex.query(api.auth.getPublicKeys, {});
|
||||||
|
const myId = localStorage.getItem('userId');
|
||||||
|
setAllUsers(data.filter(u => u.id !== myId));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showUserPicker && searchRef.current) {
|
if (showUserPicker && searchRef.current) {
|
||||||
searchRef.current.focus();
|
searchRef.current.focus();
|
||||||
@@ -43,57 +75,69 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
|
|||||||
u.username?.toLowerCase().includes(searchQuery.toLowerCase())
|
u.username?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCloseDM = (e, dm) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// If closing the active DM, switch back to friends
|
||||||
|
if (activeDMChannel?.channel_id === dm.channel_id) {
|
||||||
|
onSelectDM('friends');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '8px', flex: 1, display: 'flex', flexDirection: 'column' }}>
|
<div style={{ padding: '8px', flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
{/* Search / New DM Button */}
|
{/* Search Input */}
|
||||||
<button
|
<div className="dm-search-wrapper">
|
||||||
onClick={handleOpenUserPicker}
|
<input
|
||||||
style={{
|
ref={searchInputRef}
|
||||||
width: '100%',
|
type="text"
|
||||||
textAlign: 'left',
|
className="dm-search-input"
|
||||||
backgroundColor: '#202225',
|
placeholder="Find or start a conversation"
|
||||||
color: '#96989d',
|
value={searchQuery}
|
||||||
padding: '8px 12px',
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
borderRadius: '4px',
|
onFocus={handleSearchFocus}
|
||||||
border: 'none',
|
onBlur={() => {
|
||||||
fontSize: '13px',
|
setTimeout(() => setSearchFocused(false), 200);
|
||||||
marginBottom: '8px',
|
}}
|
||||||
cursor: 'pointer'
|
/>
|
||||||
}}
|
{searchFocused && searchQuery && filteredUsers.length > 0 && (
|
||||||
>
|
<div className="dm-search-dropdown">
|
||||||
Find or start a conversation
|
{filteredUsers.slice(0, 8).map(user => (
|
||||||
</button>
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className="dm-search-result"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchFocused(false);
|
||||||
|
onOpenDM(user.id, user.username);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar username={user.username} size={24} style={{ marginRight: '8px' }} />
|
||||||
|
<span>{user.username}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* User Picker Modal/Dropdown */}
|
{/* User Picker Modal */}
|
||||||
{showUserPicker && (
|
{showUserPicker && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||||
top: 0,
|
backgroundColor: 'rgba(0,0,0,0.7)', zIndex: 100,
|
||||||
left: 0,
|
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
|
||||||
zIndex: 100,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}
|
}}
|
||||||
onClick={() => setShowUserPicker(false)}
|
onClick={() => setShowUserPicker(false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#36393f',
|
backgroundColor: 'var(--bg-primary)', borderRadius: '8px', padding: '16px',
|
||||||
borderRadius: '8px',
|
width: '400px', maxHeight: '500px', display: 'flex', flexDirection: 'column'
|
||||||
padding: '16px',
|
|
||||||
width: '400px',
|
|
||||||
maxHeight: '500px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column'
|
|
||||||
}}
|
}}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h3 style={{ color: '#fff', margin: '0 0 4px 0', fontSize: '16px' }}>Select a User</h3>
|
<h3 style={{ color: 'var(--header-primary)', margin: '0 0 4px 0', fontSize: '16px' }}>Select a User</h3>
|
||||||
<p style={{ color: '#b9bbbe', fontSize: '12px', margin: '0 0 12px 0' }}>Start a new direct message conversation.</p>
|
<p style={{ color: 'var(--header-secondary)', fontSize: '12px', margin: '0 0 12px 0' }}>Start a new direct message conversation.</p>
|
||||||
<input
|
<input
|
||||||
ref={searchRef}
|
ref={searchRef}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -101,52 +145,24 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%', backgroundColor: 'var(--bg-tertiary)', border: '1px solid var(--border-subtle)',
|
||||||
backgroundColor: '#202225',
|
borderRadius: '4px', color: 'var(--text-normal)', padding: '8px 12px', fontSize: '14px',
|
||||||
border: '1px solid #040405',
|
outline: 'none', marginBottom: '8px', boxSizing: 'border-box'
|
||||||
borderRadius: '4px',
|
|
||||||
color: '#dcddde',
|
|
||||||
padding: '8px 12px',
|
|
||||||
fontSize: '14px',
|
|
||||||
outline: 'none',
|
|
||||||
marginBottom: '8px',
|
|
||||||
boxSizing: 'border-box'
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1, overflowY: 'auto', maxHeight: '300px' }}>
|
<div style={{ flex: 1, overflowY: 'auto', maxHeight: '300px' }}>
|
||||||
{filteredUsers.map(user => (
|
{filteredUsers.map(user => (
|
||||||
<div
|
<div
|
||||||
key={user.id}
|
key={user.id}
|
||||||
onClick={() => {
|
className="dm-picker-user"
|
||||||
setShowUserPicker(false);
|
onClick={() => { setShowUserPicker(false); onOpenDM(user.id, user.username); }}
|
||||||
onOpenDM(user.id, user.username);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: '#dcddde'
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.06)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'transparent'}
|
|
||||||
>
|
>
|
||||||
<div style={{
|
<Avatar username={user.username} size={32} style={{ marginRight: '12px' }} />
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: getUserColor(user.username),
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
color: 'white', fontWeight: '600', marginRight: '12px', flexShrink: 0
|
|
||||||
}}>
|
|
||||||
{(user.username ?? '?').substring(0, 1).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span style={{ fontWeight: '500' }}>{user.username}</span>
|
<span style={{ fontWeight: '500' }}>{user.username}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{filteredUsers.length === 0 && (
|
{filteredUsers.length === 0 && (
|
||||||
<div style={{ color: '#72767d', textAlign: 'center', padding: '16px', fontSize: '13px' }}>
|
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '16px', fontSize: '13px' }}>
|
||||||
No users found.
|
No users found.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -157,19 +173,8 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
|
|||||||
|
|
||||||
{/* Friends Button */}
|
{/* Friends Button */}
|
||||||
<div
|
<div
|
||||||
style={{
|
className={`dm-friends-btn ${!activeDMChannel ? 'active' : ''}`}
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '10px 8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
backgroundColor: !activeDMChannel ? 'rgba(255,255,255,0.04)' : 'transparent',
|
|
||||||
color: !activeDMChannel ? '#fff' : '#96989d',
|
|
||||||
cursor: 'pointer',
|
|
||||||
marginBottom: '16px'
|
|
||||||
}}
|
|
||||||
onClick={() => onSelectDM('friends')}
|
onClick={() => onSelectDM('friends')}
|
||||||
onMouseEnter={e => { if (activeDMChannel) e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.02)'; }}
|
|
||||||
onMouseLeave={e => { if (activeDMChannel) e.currentTarget.style.backgroundColor = 'transparent'; }}
|
|
||||||
>
|
>
|
||||||
<div style={{ marginRight: '12px' }}>
|
<div style={{ marginRight: '12px' }}>
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
@@ -183,61 +188,54 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
|
|||||||
{/* DM List Header */}
|
{/* DM List Header */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 8px 8px', color: '#96989d', fontSize: '11px', fontWeight: 'bold', textTransform: 'uppercase' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 8px 8px', color: '#96989d', fontSize: '11px', fontWeight: 'bold', textTransform: 'uppercase' }}>
|
||||||
<span>Direct Messages</span>
|
<span>Direct Messages</span>
|
||||||
<span
|
<Tooltip text="New DM" position="top">
|
||||||
style={{ cursor: 'pointer', fontSize: '16px' }}
|
<span
|
||||||
onClick={handleOpenUserPicker}
|
style={{ cursor: 'pointer', fontSize: '16px' }}
|
||||||
title="New DM"
|
onClick={handleOpenUserPicker}
|
||||||
>+</span>
|
>+</span>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* DM Channel List */}
|
{/* DM Channel List */}
|
||||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
{(dmChannels || []).map(dm => {
|
{(dmChannels || []).map(dm => {
|
||||||
const isActive = activeDMChannel?.channel_id === dm.channel_id;
|
const isActive = activeDMChannel?.channel_id === dm.channel_id;
|
||||||
|
const status = dm.other_user_status || 'online';
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={dm.channel_id}
|
key={dm.channel_id}
|
||||||
|
className={`dm-item ${isActive ? 'dm-item-active' : ''}`}
|
||||||
onClick={() => onSelectDM({ channel_id: dm.channel_id, other_username: dm.other_username })}
|
onClick={() => onSelectDM({ channel_id: dm.channel_id, other_username: dm.other_username })}
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: isActive ? '#fff' : '#96989d',
|
|
||||||
backgroundColor: isActive ? 'rgba(255,255,255,0.06)' : 'transparent'
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.02)'; }}
|
|
||||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.backgroundColor = 'transparent'; }}
|
|
||||||
>
|
>
|
||||||
<div style={{ position: 'relative', marginRight: '12px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', flex: 1, minWidth: 0 }}>
|
||||||
<div style={{
|
<div style={{ position: 'relative', marginRight: '12px', flexShrink: 0 }}>
|
||||||
width: '32px',
|
<Avatar username={dm.other_username} size={32} />
|
||||||
height: '32px',
|
<div style={{
|
||||||
borderRadius: '50%',
|
position: 'absolute', bottom: -2, right: -2,
|
||||||
backgroundColor: getUserColor(dm.other_username),
|
width: 10, height: 10, borderRadius: '50%',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
backgroundColor: STATUS_COLORS[status] || STATUS_COLORS.online,
|
||||||
color: 'white', fontWeight: '600'
|
border: '2px solid var(--bg-secondary)'
|
||||||
}}>
|
}} />
|
||||||
{(dm.other_username ?? '?').substring(0, 1).toUpperCase()}
|
</div>
|
||||||
|
<div style={{ overflow: 'hidden', flex: 1 }}>
|
||||||
|
<div style={{ color: isActive ? 'var(--header-primary)' : 'var(--text-normal)', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', fontWeight: '500' }}>
|
||||||
|
{dm.other_username}
|
||||||
|
</div>
|
||||||
|
<div className="dm-item-status">
|
||||||
|
{STATUS_LABELS[status] || 'Online'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
|
||||||
position: 'absolute', bottom: -2, right: -2,
|
|
||||||
width: 10, height: 10, borderRadius: '50%',
|
|
||||||
backgroundColor: '#3ba55c',
|
|
||||||
border: '2px solid #2f3136'
|
|
||||||
}} />
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflow: 'hidden' }}>
|
<div className="dm-close-btn" onClick={(e) => handleCloseDM(e, dm)}>
|
||||||
<div style={{ color: isActive ? '#fff' : '#dcddde', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', fontWeight: '500' }}>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
{dm.other_username}
|
<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"/>
|
||||||
</div>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(!dmChannels || dmChannels.length === 0) && (
|
{(!dmChannels || dmChannels.length === 0) && (
|
||||||
<div style={{ color: '#72767d', fontSize: '13px', textAlign: 'center', padding: '16px 8px' }}>
|
<div style={{ color: 'var(--text-muted)', fontSize: '13px', textAlign: 'center', padding: '16px 8px' }}>
|
||||||
No DMs yet. Click + to start a conversation.
|
No DMs yet. Click + to start a conversation.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useQuery } from 'convex/react';
|
import { useQuery } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
|
import Avatar from './Avatar';
|
||||||
|
|
||||||
const FriendsView = ({ onOpenDM }) => {
|
const FriendsView = ({ onOpenDM }) => {
|
||||||
const [activeTab, setActiveTab] = useState('Online');
|
const [activeTab, setActiveTab] = useState('Online');
|
||||||
|
const [addFriendSearch, setAddFriendSearch] = useState('');
|
||||||
|
|
||||||
const myId = localStorage.getItem('userId');
|
const myId = localStorage.getItem('userId');
|
||||||
|
|
||||||
// Reactive query for all users' public keys
|
|
||||||
const allUsers = useQuery(api.auth.getPublicKeys) || [];
|
const allUsers = useQuery(api.auth.getPublicKeys) || [];
|
||||||
const users = allUsers.filter(u => u.id !== myId);
|
const users = allUsers.filter(u => u.id !== myId);
|
||||||
|
|
||||||
@@ -21,14 +22,26 @@ const FriendsView = ({ onOpenDM }) => {
|
|||||||
return colors[Math.abs(hash) % colors.length];
|
return colors[Math.abs(hash) % colors.length];
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredUsers = users;
|
const STATUS_COLORS = {
|
||||||
|
online: '#3ba55c',
|
||||||
|
idle: '#faa61a',
|
||||||
|
dnd: '#ed4245',
|
||||||
|
invisible: '#747f8d',
|
||||||
|
offline: '#747f8d',
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredUsers = activeTab === 'Online'
|
||||||
|
? users.filter(u => u.status !== 'offline' && u.status !== 'invisible')
|
||||||
|
: activeTab === 'Add Friend'
|
||||||
|
? users.filter(u => u.username?.toLowerCase().includes(addFriendSearch.toLowerCase()))
|
||||||
|
: users;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, backgroundColor: 'hsl(240 calc(1*7.143%) 10.98% /1)', height: '100vh' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, backgroundColor: 'var(--bg-primary)', height: '100vh' }}>
|
||||||
{/* Top Bar */}
|
{/* Top Bar */}
|
||||||
<div style={{
|
<div style={{
|
||||||
height: '48px',
|
height: '48px',
|
||||||
borderBottom: '1px solid #26272d',
|
borderBottom: '1px solid var(--border-subtle)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: '0 16px',
|
padding: '0 16px',
|
||||||
@@ -36,22 +49,23 @@ const FriendsView = ({ onOpenDM }) => {
|
|||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
flexShrink: 0
|
flexShrink: 0
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', marginRight: '16px', paddingRight: '16px', borderRight: '1px solid #4a4c52' }}>
|
<div style={{ display: 'flex', alignItems: 'center', marginRight: '16px', paddingRight: '16px', borderRight: '1px solid var(--border-subtle)' }}>
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" style={{ marginRight: 8, color: '#72767d' }}>
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" style={{ marginRight: 8, color: 'var(--text-muted)' }}>
|
||||||
<path d="M13.5 2C13.5 2.82843 12.8284 3.5 12 3.5C11.1716 3.5 10.5 2.82843 10.5 2C10.5 1.17157 11.1716 0.5 12 0.5C12.8284 0.5 13.5 1.17157 13.5 2Z" fill="currentColor"/>
|
<path d="M13.5 2C13.5 2.82843 12.8284 3.5 12 3.5C11.1716 3.5 10.5 2.82843 10.5 2C10.5 1.17157 11.1716 0.5 12 0.5C12.8284 0.5 13.5 1.17157 13.5 2Z" fill="currentColor"/>
|
||||||
<path d="M7 13C7 11.8954 7.89543 11 9 11H15C16.1046 11 17 11.8954 17 13V15H7V13Z" fill="currentColor"/>
|
<path d="M7 13C7 11.8954 7.89543 11 9 11H15C16.1046 11 17 11.8954 17 13V15H7V13Z" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
Friends
|
Friends
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '16px' }}>
|
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||||
{['Online', 'All'].map(tab => (
|
{['Online', 'All'].map(tab => (
|
||||||
<div
|
<div
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
|
className="friends-tab"
|
||||||
style={{
|
style={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: activeTab === tab ? '#fff' : '#b9bbbe',
|
color: activeTab === tab ? 'var(--header-primary)' : 'var(--header-secondary)',
|
||||||
backgroundColor: activeTab === tab ? 'rgba(255,255,255,0.06)' : 'transparent',
|
backgroundColor: activeTab === tab ? 'rgba(255,255,255,0.06)' : 'transparent',
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
borderRadius: '4px'
|
borderRadius: '4px'
|
||||||
@@ -60,18 +74,55 @@ const FriendsView = ({ onOpenDM }) => {
|
|||||||
{tab}
|
{tab}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<div
|
||||||
|
onClick={() => setActiveTab('Add Friend')}
|
||||||
|
className="friends-tab"
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: activeTab === 'Add Friend' ? '#fff' : '#3ba55c',
|
||||||
|
backgroundColor: activeTab === 'Add Friend' ? '#3ba55c' : 'transparent',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Friend
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'Add Friend' && (
|
||||||
|
<div style={{ padding: '16px 20px' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search for a user to start a conversation..."
|
||||||
|
value={addFriendSearch}
|
||||||
|
onChange={(e) => setAddFriendSearch(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'var(--text-normal)',
|
||||||
|
padding: '10px 14px',
|
||||||
|
fontSize: '14px',
|
||||||
|
outline: 'none',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* List Header */}
|
{/* List Header */}
|
||||||
<div style={{ padding: '16px 20px 8px' }}>
|
<div style={{ padding: '16px 20px 8px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: '#b9bbbe',
|
color: 'var(--header-secondary)',
|
||||||
textTransform: 'uppercase'
|
textTransform: 'uppercase'
|
||||||
}}>
|
}}>
|
||||||
{activeTab} — {filteredUsers.length}
|
{activeTab === 'Add Friend' ? 'USERS' : activeTab} — {filteredUsers.length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -80,56 +131,43 @@ const FriendsView = ({ onOpenDM }) => {
|
|||||||
{filteredUsers.map(user => (
|
{filteredUsers.map(user => (
|
||||||
<div
|
<div
|
||||||
key={user.id}
|
key={user.id}
|
||||||
style={{
|
className="friend-item"
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '10px 0',
|
|
||||||
borderTop: '1px solid #3f4147',
|
|
||||||
cursor: 'pointer',
|
|
||||||
':hover': { backgroundColor: 'rgba(255,255,255,0.02)' }
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<div style={{ position: 'relative', marginRight: '12px' }}>
|
<div style={{ position: 'relative', marginRight: '12px' }}>
|
||||||
<div style={{
|
<Avatar username={user.username} avatarUrl={user.avatarUrl} size={32} />
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: getUserColor(user.username),
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
color: 'white', fontWeight: '600'
|
|
||||||
}}>
|
|
||||||
{(user.username ?? '?').substring(0,1).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', bottom: -2, right: -2,
|
position: 'absolute', bottom: -2, right: -2,
|
||||||
width: 10, height: 10, borderRadius: '50%',
|
width: 10, height: 10, borderRadius: '50%',
|
||||||
backgroundColor: '#3ba55c',
|
backgroundColor: STATUS_COLORS[user.status] || STATUS_COLORS.online,
|
||||||
border: '2px solid #36393f'
|
border: '2px solid var(--bg-primary)'
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ color: '#fff', fontWeight: '600' }}>
|
<div style={{ color: 'var(--header-primary)', fontWeight: '600' }}>
|
||||||
{user.username ?? 'Unknown'}
|
{user.username ?? 'Unknown'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#b9bbbe', fontSize: '12px' }}>
|
<div style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>
|
||||||
Online
|
{user.status === 'dnd' ? 'Do Not Disturb' : (user.status || 'Online').charAt(0).toUpperCase() + (user.status || 'online').slice(1)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
<div
|
<div
|
||||||
style={{ padding: 8, backgroundColor: '#2f3136', borderRadius: '50%', cursor: 'pointer' }}
|
className="friend-action-btn"
|
||||||
onClick={() => onOpenDM && onOpenDM(user.id, user.username)}
|
onClick={() => onOpenDM && onOpenDM(user.id, user.username)}
|
||||||
title="Message"
|
|
||||||
>
|
>
|
||||||
💬
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M4.79805 3C3.80445 3 2.99805 3.8055 2.99805 4.8V15.6C2.99805 16.5936 3.80445 17.4 4.79805 17.4H8.39805L11.998 21L15.598 17.4H19.198C20.1925 17.4 20.998 16.5936 20.998 15.6V4.8C20.998 3.8055 20.1925 3 19.198 3H4.79805Z" />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: 8, backgroundColor: '#2f3136', borderRadius: '50%', cursor: 'pointer' }}>
|
<div className="friend-action-btn">
|
||||||
⋮
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 16C13.1046 16 14 15.1046 14 14C14 12.8954 13.1046 12 12 12C10.8954 12 10 12.8954 10 14C10 15.1046 10.8954 16 12 16Z" />
|
||||||
|
<path d="M12 10C13.1046 10 14 9.10457 14 8C14 6.89543 13.1046 6 12 6C10.8954 6 10 6.89543 10 8C10 9.10457 10.8954 10 12 10Z" />
|
||||||
|
<path d="M12 22C13.1046 22 14 21.1046 14 20C14 18.8954 13.1046 18 12 18C10.8954 18 10 18.8954 10 20C10 21.1046 10.8954 22 12 22Z" />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const EmojiItem = ({ emoji, onSelect }) => (
|
|||||||
onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })}
|
onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })}
|
||||||
title={`:${emoji.name}:`}
|
title={`:${emoji.name}:`}
|
||||||
style={{ cursor: 'pointer', padding: '4px', borderRadius: '4px' }}
|
style={{ cursor: 'pointer', padding: '4px', borderRadius: '4px' }}
|
||||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'}
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--background-modifier-hover)'}
|
||||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
>
|
>
|
||||||
<img src={emoji.src} alt={emoji.name} style={{ width: '32px', height: '32px' }} loading="lazy" />
|
<img src={emoji.src} alt={emoji.name} style={{ width: '32px', height: '32px' }} loading="lazy" />
|
||||||
@@ -32,7 +32,7 @@ const GifContent = ({ search, results, categories, onSelect, onCategoryClick })
|
|||||||
onClick={() => onSelect(gif.media_formats?.gif?.url || gif.src)}
|
onClick={() => onSelect(gif.media_formats?.gif?.url || gif.src)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{results.length === 0 && <div style={{ color: '#b9bbbe', gridColumn: 'span 2', textAlign: 'center' }}>No GIFs found</div>}
|
{results.length === 0 && <div style={{ color: 'var(--header-secondary)', gridColumn: 'span 2', textAlign: 'center' }}>No GIFs found</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ const GifContent = ({ search, results, categories, onSelect, onCategoryClick })
|
|||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
backgroundColor: '#202225'
|
backgroundColor: 'var(--bg-tertiary)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
@@ -123,7 +123,7 @@ const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory })
|
|||||||
padding: '4px',
|
padding: '4px',
|
||||||
borderRadius: '4px'
|
borderRadius: '4px'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'}
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--background-modifier-hover)'}
|
||||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -132,7 +132,7 @@ const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory })
|
|||||||
height="12"
|
height="12"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#b9bbbe"
|
stroke="var(--header-secondary)"
|
||||||
strokeWidth="3"
|
strokeWidth="3"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
@@ -145,7 +145,7 @@ const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory })
|
|||||||
<polyline points="6 9 12 15 18 9"></polyline>
|
<polyline points="6 9 12 15 18 9"></polyline>
|
||||||
</svg>
|
</svg>
|
||||||
<h3 style={{
|
<h3 style={{
|
||||||
color: '#b9bbbe',
|
color: 'var(--header-secondary)',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
@@ -235,7 +235,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
|||||||
right: '0',
|
right: '0',
|
||||||
width: '400px',
|
width: '400px',
|
||||||
height: '450px',
|
height: '450px',
|
||||||
backgroundColor: '#2f3136',
|
backgroundColor: 'var(--embed-background)',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
boxShadow: '0 8px 16px rgba(0,0,0,0.24)',
|
boxShadow: '0 8px 16px rgba(0,0,0,0.24)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -246,7 +246,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header / Tabs */}
|
{/* Header / Tabs */}
|
||||||
<div style={{ padding: '16px 16px 8px 16px', display: 'flex', gap: '16px', borderBottom: '1px solid #202225' }}>
|
<div style={{ padding: '16px 16px 8px 16px', display: 'flex', gap: '16px', borderBottom: '1px solid var(--bg-tertiary)' }}>
|
||||||
{['GIFs', 'Stickers', 'Emoji'].map(tab => (
|
{['GIFs', 'Stickers', 'Emoji'].map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
@@ -254,12 +254,12 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
|||||||
style={{
|
style={{
|
||||||
background: 'none',
|
background: 'none',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
color: activeTab === tab ? '#fff' : '#b9bbbe',
|
color: activeTab === tab ? 'var(--header-primary)' : 'var(--header-secondary)',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
paddingBottom: '4px',
|
paddingBottom: '4px',
|
||||||
borderBottom: activeTab === tab ? '2px solid #5865f2' : '2px solid transparent'
|
borderBottom: activeTab === tab ? '2px solid var(--brand-experiment)' : '2px solid transparent'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
@@ -270,7 +270,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
|||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div style={{ padding: '8px 16px' }}>
|
<div style={{ padding: '8px 16px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
backgroundColor: '#202225',
|
backgroundColor: 'var(--bg-tertiary)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -292,14 +292,14 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
color: '#dcddde',
|
color: 'var(--text-normal)',
|
||||||
padding: '8px',
|
padding: '8px',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
outline: 'none'
|
outline: 'none'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ padding: '4px' }}>
|
<div style={{ padding: '4px' }}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#b9bbbe" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--header-secondary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<circle cx="11" cy="11" r="8"></circle>
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -310,7 +310,7 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
|||||||
{/* Content Area */}
|
{/* Content Area */}
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 16px 16px 16px' }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '0 16px 16px 16px' }}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ color: '#b9bbbe', textAlign: 'center', padding: '20px' }}>Loading...</div>
|
<div style={{ color: 'var(--header-secondary)', textAlign: 'center', padding: '20px' }}>Loading...</div>
|
||||||
) : activeTab === 'GIFs' ? (
|
) : activeTab === 'GIFs' ? (
|
||||||
<GifContent search={search} results={results} categories={categories} onSelect={onSelect} onCategoryClick={setSearch} />
|
<GifContent search={search} results={results} categories={categories} onSelect={onSelect} onCategoryClick={setSearch} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
130
Frontend/Electron/src/components/MembersList.jsx
Normal file
130
Frontend/Electron/src/components/MembersList.jsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
|
import { api } from '../../../../convex/_generated/api';
|
||||||
|
|
||||||
|
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||||
|
|
||||||
|
function getUserColor(name) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
online: '#3ba55c',
|
||||||
|
idle: '#faa61a',
|
||||||
|
dnd: '#ed4245',
|
||||||
|
invisible: '#747f8d',
|
||||||
|
offline: '#747f8d',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MembersList = ({ channelId, visible, onMemberClick }) => {
|
||||||
|
const members = useQuery(
|
||||||
|
api.members.getChannelMembers,
|
||||||
|
channelId ? { channelId } : "skip"
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const onlineMembers = members.filter(m => m.status !== 'offline' && m.status !== 'invisible');
|
||||||
|
const offlineMembers = members.filter(m => m.status === 'offline' || m.status === 'invisible');
|
||||||
|
|
||||||
|
// Group online members by highest hoisted role
|
||||||
|
const roleGroups = {};
|
||||||
|
const ungrouped = [];
|
||||||
|
|
||||||
|
onlineMembers.forEach(member => {
|
||||||
|
const hoistedRole = member.roles.find(r => r.isHoist && r.name !== '@everyone');
|
||||||
|
if (hoistedRole) {
|
||||||
|
const key = `${hoistedRole.position}_${hoistedRole.name}`;
|
||||||
|
if (!roleGroups[key]) {
|
||||||
|
roleGroups[key] = { role: hoistedRole, members: [] };
|
||||||
|
}
|
||||||
|
roleGroups[key].members.push(member);
|
||||||
|
} else {
|
||||||
|
ungrouped.push(member);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort groups by position descending
|
||||||
|
const sortedGroups = Object.values(roleGroups).sort((a, b) => b.role.position - a.role.position);
|
||||||
|
|
||||||
|
const renderMember = (member) => {
|
||||||
|
const topRole = member.roles.length > 0 ? member.roles[0] : null;
|
||||||
|
const nameColor = topRole && topRole.name !== '@everyone' ? topRole.color : '#fff';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className="member-item"
|
||||||
|
onClick={() => onMemberClick && onMemberClick(member)}
|
||||||
|
style={member.status === 'offline' || member.status === 'invisible' ? { opacity: 0.3 } : {}}
|
||||||
|
>
|
||||||
|
<div className="member-avatar-wrapper">
|
||||||
|
{member.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
className="member-avatar"
|
||||||
|
src={member.avatarUrl}
|
||||||
|
alt={member.username}
|
||||||
|
style={{ objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="member-avatar"
|
||||||
|
style={{ backgroundColor: getUserColor(member.username) }}
|
||||||
|
>
|
||||||
|
{member.username.substring(0, 1).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="member-status-dot"
|
||||||
|
style={{ backgroundColor: STATUS_COLORS[member.status] || STATUS_COLORS.online }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="member-info">
|
||||||
|
<span className="member-name" style={{ color: nameColor }}>
|
||||||
|
{member.username}
|
||||||
|
</span>
|
||||||
|
{member.customStatus && (
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{member.customStatus}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="members-list">
|
||||||
|
{sortedGroups.map(group => (
|
||||||
|
<React.Fragment key={group.role.name}>
|
||||||
|
<div className="members-role-header">
|
||||||
|
{group.role.name} — {group.members.length}
|
||||||
|
</div>
|
||||||
|
{group.members.map(renderMember)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
{ungrouped.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="members-role-header">
|
||||||
|
ONLINE — {ungrouped.length}
|
||||||
|
</div>
|
||||||
|
{ungrouped.map(renderMember)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{offlineMembers.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="members-role-header">
|
||||||
|
OFFLINE — {offlineMembers.length}
|
||||||
|
</div>
|
||||||
|
{offlineMembers.map(renderMember)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MembersList;
|
||||||
43
Frontend/Electron/src/components/MentionMenu.jsx
Normal file
43
Frontend/Electron/src/components/MentionMenu.jsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import Avatar from './Avatar';
|
||||||
|
|
||||||
|
const MentionMenu = ({ members, selectedIndex, onSelect, onHover }) => {
|
||||||
|
const scrollerRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrollerRef.current) return;
|
||||||
|
const selected = scrollerRef.current.querySelector('.mention-menu-row.selected');
|
||||||
|
if (selected) selected.scrollIntoView({ block: 'nearest' });
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
if (!members || members.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mention-menu">
|
||||||
|
<div className="mention-menu-header">Members</div>
|
||||||
|
<div className="mention-menu-scroller" ref={scrollerRef}>
|
||||||
|
{members.map((member, i) => {
|
||||||
|
const topRole = member.roles && member.roles.length > 0 ? member.roles[0] : null;
|
||||||
|
const nameColor = topRole?.color || undefined;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className={`mention-menu-row${i === selectedIndex ? ' selected' : ''}`}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => onSelect(member)}
|
||||||
|
onMouseEnter={() => onHover(i)}
|
||||||
|
>
|
||||||
|
<Avatar username={member.username} avatarUrl={member.avatarUrl} size={24} />
|
||||||
|
<span className="mention-menu-row-primary" style={nameColor ? { color: nameColor } : undefined}>
|
||||||
|
{member.username}
|
||||||
|
</span>
|
||||||
|
<span className="mention-menu-row-secondary">{member.username}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MentionMenu;
|
||||||
89
Frontend/Electron/src/components/PinnedMessagesPanel.jsx
Normal file
89
Frontend/Electron/src/components/PinnedMessagesPanel.jsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useQuery, useMutation } from 'convex/react';
|
||||||
|
import { api } from '../../../../convex/_generated/api';
|
||||||
|
|
||||||
|
const PinnedMessagesPanel = ({ channelId, visible, onClose, channelKey, onJumpToMessage }) => {
|
||||||
|
const [decryptedPins, setDecryptedPins] = useState([]);
|
||||||
|
|
||||||
|
const pinnedMessages = useQuery(
|
||||||
|
api.messages.listPinned,
|
||||||
|
channelId ? { channelId } : "skip"
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
const unpinMutation = useMutation(api.messages.pin);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pinnedMessages.length || !channelKey) {
|
||||||
|
setDecryptedPins([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const decrypt = async () => {
|
||||||
|
const results = await Promise.all(
|
||||||
|
pinnedMessages.map(async (msg) => {
|
||||||
|
try {
|
||||||
|
const TAG_LENGTH = 32;
|
||||||
|
const tag = msg.ciphertext.slice(-TAG_LENGTH);
|
||||||
|
const content = msg.ciphertext.slice(0, -TAG_LENGTH);
|
||||||
|
const decrypted = await window.cryptoAPI.decryptData(content, channelKey, msg.nonce, tag);
|
||||||
|
return { ...msg, content: decrypted };
|
||||||
|
} catch {
|
||||||
|
return { ...msg, content: '[Encrypted Message]' };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (!cancelled) setDecryptedPins(results);
|
||||||
|
};
|
||||||
|
decrypt();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [pinnedMessages, channelKey]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pinned-panel">
|
||||||
|
<div className="pinned-panel-header">
|
||||||
|
<h3>Pinned Messages</h3>
|
||||||
|
<button className="pinned-panel-close" onClick={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="pinned-panel-content">
|
||||||
|
{decryptedPins.length === 0 ? (
|
||||||
|
<div className="pinned-panel-empty">
|
||||||
|
No pinned messages in this channel yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
decryptedPins.map(msg => (
|
||||||
|
<div key={msg.id} className="pinned-message-item">
|
||||||
|
<div className="pinned-message-header">
|
||||||
|
<span className="pinned-message-author">{msg.username}</span>
|
||||||
|
<span className="pinned-message-date">
|
||||||
|
{new Date(msg.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pinned-message-content">
|
||||||
|
{msg.content?.startsWith('{') ? '[Attachment]' : msg.content}
|
||||||
|
</div>
|
||||||
|
<div className="pinned-message-actions">
|
||||||
|
<button
|
||||||
|
className="pinned-action-btn"
|
||||||
|
onClick={() => onJumpToMessage && onJumpToMessage(msg.id)}
|
||||||
|
>
|
||||||
|
Jump
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pinned-action-btn pinned-action-danger"
|
||||||
|
onClick={() => unpinMutation({ id: msg.id, pinned: false })}
|
||||||
|
>
|
||||||
|
Unpin
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PinnedMessagesPanel;
|
||||||
@@ -67,7 +67,7 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
display: 'flex', flexDirection: 'column', alignItems: 'flex-end', padding: '60px 6px 60px 20px'
|
display: 'flex', flexDirection: 'column', alignItems: 'flex-end', padding: '60px 6px 60px 20px'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ width: '100%', padding: '0 10px' }}>
|
<div style={{ width: '100%', padding: '0 10px' }}>
|
||||||
<div style={{ fontSize: '12px', fontWeight: '700', color: '#96989d', marginBottom: '6px', textTransform: 'uppercase' }}>
|
<div style={{ fontSize: '12px', fontWeight: '700', color: 'var(--text-muted)', marginBottom: '6px', textTransform: 'uppercase' }}>
|
||||||
Server Settings
|
Server Settings
|
||||||
</div>
|
</div>
|
||||||
{['Overview', 'Roles', 'Members'].map(tab => (
|
{['Overview', 'Roles', 'Members'].map(tab => (
|
||||||
@@ -76,8 +76,8 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 10px', borderRadius: '4px',
|
padding: '6px 10px', borderRadius: '4px',
|
||||||
backgroundColor: activeTab === tab ? '#393c43' : 'transparent',
|
backgroundColor: activeTab === tab ? 'var(--background-modifier-selected)' : 'transparent',
|
||||||
color: activeTab === tab ? '#fff' : '#b9bbbe',
|
color: activeTab === tab ? 'var(--header-primary)' : 'var(--header-secondary)',
|
||||||
cursor: 'pointer', marginBottom: '2px', fontSize: '15px'
|
cursor: 'pointer', marginBottom: '2px', fontSize: '15px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -90,16 +90,16 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
|
|
||||||
const canManageRoles = myPermissions.manage_roles;
|
const canManageRoles = myPermissions.manage_roles;
|
||||||
const disabledOpacity = canManageRoles ? 1 : 0.5;
|
const disabledOpacity = canManageRoles ? 1 : 0.5;
|
||||||
const labelStyle = { display: 'block', color: '#b9bbbe', fontSize: '12px', fontWeight: '700', marginBottom: 8 };
|
const labelStyle = { display: 'block', color: 'var(--header-secondary)', fontSize: '12px', fontWeight: '700', marginBottom: 8 };
|
||||||
const editableRoles = roles.filter(r => r.name !== 'Owner');
|
const editableRoles = roles.filter(r => r.name !== 'Owner');
|
||||||
|
|
||||||
const renderRolesTab = () => (
|
const renderRolesTab = () => (
|
||||||
<div style={{ display: 'flex', height: '100%' }}>
|
<div style={{ display: 'flex', height: '100%' }}>
|
||||||
<div style={{ width: '200px', borderRight: '1px solid #3f4147', marginRight: '20px' }}>
|
<div style={{ width: '200px', borderRight: '1px solid var(--border-subtle)', marginRight: '20px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '10px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '10px' }}>
|
||||||
<h3 style={{ color: '#b9bbbe', fontSize: '12px' }}>ROLES</h3>
|
<h3 style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>ROLES</h3>
|
||||||
{canManageRoles && (
|
{canManageRoles && (
|
||||||
<button onClick={handleCreateRole} style={{ background: 'transparent', border: 'none', color: '#b9bbbe', cursor: 'pointer' }}>+</button>
|
<button onClick={handleCreateRole} style={{ background: 'transparent', border: 'none', color: 'var(--header-secondary)', cursor: 'pointer' }}>+</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{editableRoles.map(r => (
|
{editableRoles.map(r => (
|
||||||
@@ -108,7 +108,7 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
onClick={() => setSelectedRole(r)}
|
onClick={() => setSelectedRole(r)}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px',
|
padding: '6px',
|
||||||
backgroundColor: selectedRole?._id === r._id ? '#40444b' : 'transparent',
|
backgroundColor: selectedRole?._id === r._id ? 'var(--background-modifier-selected)' : 'transparent',
|
||||||
borderRadius: '4px', cursor: 'pointer', color: r.color || '#b9bbbe',
|
borderRadius: '4px', cursor: 'pointer', color: r.color || '#b9bbbe',
|
||||||
display: 'flex', alignItems: 'center'
|
display: 'flex', alignItems: 'center'
|
||||||
}}
|
}}
|
||||||
@@ -121,14 +121,14 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
|
|
||||||
{selectedRole ? (
|
{selectedRole ? (
|
||||||
<div className="no-scrollbar" style={{ flex: 1, overflowY: 'auto' }}>
|
<div className="no-scrollbar" style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
<h2 style={{ color: 'white', marginTop: 0 }}>Edit Role - {selectedRole.name}</h2>
|
<h2 style={{ color: 'var(--header-primary)', marginTop: 0 }}>Edit Role - {selectedRole.name}</h2>
|
||||||
|
|
||||||
<label style={labelStyle}>ROLE NAME</label>
|
<label style={labelStyle}>ROLE NAME</label>
|
||||||
<input
|
<input
|
||||||
value={selectedRole.name}
|
value={selectedRole.name}
|
||||||
onChange={(e) => handleUpdateRole(selectedRole._id, { name: e.target.value })}
|
onChange={(e) => handleUpdateRole(selectedRole._id, { name: e.target.value })}
|
||||||
disabled={!canManageRoles}
|
disabled={!canManageRoles}
|
||||||
style={{ width: '100%', padding: 10, background: '#202225', border: 'none', borderRadius: 4, color: 'white', marginBottom: 20, opacity: disabledOpacity }}
|
style={{ width: '100%', padding: 10, background: 'var(--bg-tertiary)', border: 'none', borderRadius: 4, color: 'var(--header-primary)', marginBottom: 20, opacity: disabledOpacity }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label style={labelStyle}>ROLE COLOR</label>
|
<label style={labelStyle}>ROLE COLOR</label>
|
||||||
@@ -142,8 +142,8 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
|
|
||||||
<label style={labelStyle}>PERMISSIONS</label>
|
<label style={labelStyle}>PERMISSIONS</label>
|
||||||
{['manage_channels', 'manage_roles', 'create_invite', 'embed_links', 'attach_files'].map(perm => (
|
{['manage_channels', 'manage_roles', 'create_invite', 'embed_links', 'attach_files'].map(perm => (
|
||||||
<div key={perm} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10, paddingBottom: 10, borderBottom: '1px solid #3f4147' }}>
|
<div key={perm} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10, paddingBottom: 10, borderBottom: '1px solid var(--border-subtle)' }}>
|
||||||
<span style={{ color: 'white', textTransform: 'capitalize' }}>{perm.replace('_', ' ')}</span>
|
<span style={{ color: 'var(--header-primary)', textTransform: 'capitalize' }}>{perm.replace('_', ' ')}</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedRole.permissions?.[perm] || false}
|
checked={selectedRole.permissions?.[perm] || false}
|
||||||
@@ -165,24 +165,24 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ color: '#b9bbbe' }}>Select a role to edit</div>
|
<div style={{ color: 'var(--header-secondary)' }}>Select a role to edit</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderMembersTab = () => (
|
const renderMembersTab = () => (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ color: 'white' }}>Members</h2>
|
<h2 style={{ color: 'var(--header-primary)' }}>Members</h2>
|
||||||
{members.map(m => (
|
{members.map(m => (
|
||||||
<div key={m.id} style={{ display: 'flex', alignItems: 'center', padding: '10px', borderBottom: '1px solid #3f4147' }}>
|
<div key={m.id} style={{ display: 'flex', alignItems: 'center', padding: '10px', borderBottom: '1px solid var(--border-subtle)' }}>
|
||||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#5865F2', marginRight: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white' }}>
|
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#5865F2', marginRight: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--header-primary)' }}>
|
||||||
{m.username[0].toUpperCase()}
|
{m.username[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ color: 'white', fontWeight: 'bold' }}>{m.username}</div>
|
<div style={{ color: 'var(--header-primary)', fontWeight: 'bold' }}>{m.username}</div>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
{m.roles?.map(r => (
|
{m.roles?.map(r => (
|
||||||
<span key={r._id} style={{ fontSize: 10, background: r.color, color: 'white', padding: '2px 4px', borderRadius: 4 }}>
|
<span key={r._id} style={{ fontSize: 10, background: r.color, color: 'var(--header-primary)', padding: '2px 4px', borderRadius: 4 }}>
|
||||||
{r.name}
|
{r.name}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -217,17 +217,17 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case 'Roles': return renderRolesTab();
|
case 'Roles': return renderRolesTab();
|
||||||
case 'Members': return renderMembersTab();
|
case 'Members': return renderMembersTab();
|
||||||
default: return <div style={{ color: '#b9bbbe' }}>Server Name: Secure Chat<br/>Region: US-East</div>;
|
default: return <div style={{ color: 'var(--header-secondary)' }}>Server Name: Secure Chat<br/>Region: US-East</div>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'hsl(240 calc(1*7.143%) 10.98% /1)', zIndex: 1000, display: 'flex', color: '#dcddde' }}>
|
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'var(--bg-primary)', zIndex: 1000, display: 'flex', color: 'var(--text-normal)' }}>
|
||||||
{renderSidebar()}
|
{renderSidebar()}
|
||||||
<div style={{ flex: 1, padding: '60px 40px' }}>
|
<div style={{ flex: 1, padding: '60px 40px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 20 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||||
<h2 style={{ color: 'white', margin: 0 }}>{activeTab}</h2>
|
<h2 style={{ color: 'var(--header-primary)', margin: 0 }}>{activeTab}</h2>
|
||||||
<button onClick={onClose} style={{ background: 'transparent', border: '1px solid #b9bbbe', borderRadius: '50%', width: 36, height: 36, color: '#b9bbbe', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>✕</button>
|
<button onClick={onClose} style={{ background: 'transparent', border: '1px solid #b9bbbe', borderRadius: '50%', width: 36, height: 36, color: 'var(--header-secondary)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useConvex } from 'convex/react';
|
import { useConvex, useMutation } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
|
import Tooltip from './Tooltip';
|
||||||
import { useVoice } from '../contexts/VoiceContext';
|
import { useVoice } from '../contexts/VoiceContext';
|
||||||
import ChannelSettingsModal from './ChannelSettingsModal';
|
import ChannelSettingsModal from './ChannelSettingsModal';
|
||||||
import ServerSettingsModal from './ServerSettingsModal';
|
import ServerSettingsModal from './ServerSettingsModal';
|
||||||
import ScreenShareModal from './ScreenShareModal';
|
import ScreenShareModal from './ScreenShareModal';
|
||||||
import DMList from './DMList';
|
import DMList from './DMList';
|
||||||
|
import Avatar from './Avatar';
|
||||||
|
import ThemeSelector from './ThemeSelector';
|
||||||
import { Track } from 'livekit-client';
|
import { Track } from 'livekit-client';
|
||||||
import muteIcon from '../assets/icons/mute.svg';
|
import muteIcon from '../assets/icons/mute.svg';
|
||||||
import mutedIcon from '../assets/icons/muted.svg';
|
import mutedIcon from '../assets/icons/muted.svg';
|
||||||
@@ -74,47 +77,80 @@ const ColoredIcon = ({ src, color, size = '20px' }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const UserControlPanel = ({ username }) => {
|
const VoiceTimer = () => {
|
||||||
|
const [elapsed, setElapsed] = React.useState(0);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const start = Date.now();
|
||||||
|
const interval = setInterval(() => setElapsed(Math.floor((Date.now() - start) / 1000)), 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
const hours = Math.floor(elapsed / 3600);
|
||||||
|
const mins = Math.floor((elapsed % 3600) / 60);
|
||||||
|
const secs = elapsed % 60;
|
||||||
|
const time = hours > 0
|
||||||
|
? `${hours}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
|
||||||
|
: `${mins}:${String(secs).padStart(2, '0')}`;
|
||||||
|
return <span className="voice-timer">{time}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: 'online', label: 'Online', color: '#3ba55c' },
|
||||||
|
{ value: 'idle', label: 'Idle', color: '#faa61a' },
|
||||||
|
{ value: 'dnd', label: 'Do Not Disturb', color: '#ed4245' },
|
||||||
|
{ value: 'invisible', label: 'Invisible', color: '#747f8d' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const UserControlPanel = ({ username, userId }) => {
|
||||||
const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState } = useVoice();
|
const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState } = useVoice();
|
||||||
|
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
||||||
|
const [showThemeSelector, setShowThemeSelector] = useState(false);
|
||||||
|
const [currentStatus, setCurrentStatus] = useState('online');
|
||||||
|
const updateStatusMutation = useMutation(api.auth.updateStatus);
|
||||||
|
|
||||||
const effectiveMute = isMuted || isDeafened;
|
const effectiveMute = isMuted || isDeafened;
|
||||||
|
const statusColor = STATUS_OPTIONS.find(s => s.value === currentStatus)?.color || '#3ba55c';
|
||||||
|
|
||||||
|
const handleStatusChange = async (status) => {
|
||||||
|
setCurrentStatus(status);
|
||||||
|
setShowStatusMenu(false);
|
||||||
|
if (userId) {
|
||||||
|
try {
|
||||||
|
await updateStatusMutation({ userId, status });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to update status:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
height: '64px',
|
height: '64px',
|
||||||
margin: '0 8px 8px 8px',
|
margin: '0 8px 8px 8px',
|
||||||
borderRadius: connectionState === 'connected' ? '0px 0px 8px 8px' : '8px',
|
borderRadius: connectionState === 'connected' ? '0px 0px 8px 8px' : '8px',
|
||||||
backgroundColor: '#292b2f',
|
backgroundColor: 'var(--panel-bg)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: '0 8px',
|
padding: '0 8px',
|
||||||
flexShrink: 0
|
flexShrink: 0,
|
||||||
|
position: 'relative'
|
||||||
}}>
|
}}>
|
||||||
{/* User Info */}
|
{showStatusMenu && (
|
||||||
<div style={{
|
<div className="status-menu">
|
||||||
display: 'flex',
|
{STATUS_OPTIONS.map(opt => (
|
||||||
alignItems: 'center',
|
<div
|
||||||
marginRight: 'auto',
|
key={opt.value}
|
||||||
padding: '4px',
|
className="status-menu-item"
|
||||||
borderRadius: '4px',
|
onClick={() => handleStatusChange(opt.value)}
|
||||||
cursor: 'pointer',
|
>
|
||||||
':hover': { backgroundColor: 'rgba(255,255,255,0.05)' }
|
<div className="status-menu-dot" style={{ backgroundColor: opt.color }} />
|
||||||
}}>
|
<span>{opt.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="user-control-info" onClick={() => setShowStatusMenu(!showStatusMenu)}>
|
||||||
<div style={{ position: 'relative', marginRight: '8px' }}>
|
<div style={{ position: 'relative', marginRight: '8px' }}>
|
||||||
<div style={{
|
<Avatar username={username} size={32} />
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: getUserColor(username || 'U'),
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color: 'white',
|
|
||||||
fontWeight: '600',
|
|
||||||
fontSize: '14px'
|
|
||||||
}}>
|
|
||||||
{(username || '?').substring(0, 1).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: '-2px',
|
bottom: '-2px',
|
||||||
@@ -122,41 +158,48 @@ const UserControlPanel = ({ username }) => {
|
|||||||
width: '10px',
|
width: '10px',
|
||||||
height: '10px',
|
height: '10px',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
backgroundColor: '#3ba55c',
|
backgroundColor: statusColor,
|
||||||
border: '2px solid #292b2f'
|
border: '2px solid var(--panel-bg)',
|
||||||
|
cursor: 'pointer'
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
<div style={{ color: 'white', fontWeight: '600', fontSize: '14px', lineHeight: '18px' }}>
|
<div style={{ color: 'var(--header-primary)', fontWeight: '600', fontSize: '14px', lineHeight: '18px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
{username || 'Unknown'}
|
{username || 'Unknown'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#b9bbbe', fontSize: '12px', lineHeight: '13px' }}>
|
<div style={{ color: 'var(--header-secondary)', fontSize: '12px', lineHeight: '13px' }}>
|
||||||
#{Math.floor(Math.random() * 9000) + 1000}
|
{STATUS_OPTIONS.find(s => s.value === currentStatus)?.label || 'Online'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<div style={{ display: 'flex' }}>
|
<div style={{ display: 'flex' }}>
|
||||||
<button onClick={toggleMute} title={effectiveMute ? "Unmute" : "Mute"} style={controlButtonStyle}>
|
<Tooltip text={effectiveMute ? "Unmute" : "Mute"} position="top">
|
||||||
<ColoredIcon
|
<button onClick={toggleMute} style={controlButtonStyle}>
|
||||||
src={effectiveMute ? mutedIcon : muteIcon}
|
<ColoredIcon
|
||||||
color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
|
src={effectiveMute ? mutedIcon : muteIcon}
|
||||||
/>
|
color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
|
||||||
</button>
|
/>
|
||||||
<button onClick={toggleDeafen} title={isDeafened ? "Undeafen" : "Deafen"} style={controlButtonStyle}>
|
</button>
|
||||||
<ColoredIcon
|
</Tooltip>
|
||||||
src={isDeafened ? defeanedIcon : defeanIcon}
|
<Tooltip text={isDeafened ? "Undeafen" : "Deafen"} position="top">
|
||||||
color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
|
<button onClick={toggleDeafen} style={controlButtonStyle}>
|
||||||
/>
|
<ColoredIcon
|
||||||
</button>
|
src={isDeafened ? defeanedIcon : defeanIcon}
|
||||||
<button title="User Settings" style={controlButtonStyle}>
|
color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
|
||||||
<ColoredIcon
|
/>
|
||||||
src={settingsIcon}
|
</button>
|
||||||
color={ICON_COLOR_DEFAULT}
|
</Tooltip>
|
||||||
/>
|
<Tooltip text="User Settings" position="top">
|
||||||
</button>
|
<button style={controlButtonStyle} onClick={() => setShowThemeSelector(true)}>
|
||||||
|
<ColoredIcon
|
||||||
|
src={settingsIcon}
|
||||||
|
color={ICON_COLOR_DEFAULT}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
{showThemeSelector && <ThemeSelector onClose={() => setShowThemeSelector(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -166,7 +209,7 @@ const UserControlPanel = ({ username }) => {
|
|||||||
const headerButtonStyle = {
|
const headerButtonStyle = {
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
color: '#b9bbbe',
|
color: 'var(--header-secondary)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '18px',
|
fontSize: '18px',
|
||||||
padding: '0 4px'
|
padding: '0 4px'
|
||||||
@@ -249,13 +292,14 @@ function getScreenCaptureConstraints(selection) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels }) => {
|
const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId }) => {
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
|
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
|
||||||
const [newChannelName, setNewChannelName] = useState('');
|
const [newChannelName, setNewChannelName] = useState('');
|
||||||
const [newChannelType, setNewChannelType] = useState('text');
|
const [newChannelType, setNewChannelType] = useState('text');
|
||||||
const [editingChannel, setEditingChannel] = useState(null);
|
const [editingChannel, setEditingChannel] = useState(null);
|
||||||
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
||||||
|
const [collapsedCategories, setCollapsedCategories] = useState({});
|
||||||
|
|
||||||
const convex = useConvex();
|
const convex = useConvex();
|
||||||
|
|
||||||
@@ -419,23 +463,22 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
<div style={{ marginLeft: 32, marginBottom: 8 }}>
|
<div style={{ marginLeft: 32, marginBottom: 8 }}>
|
||||||
{users.map(user => (
|
{users.map(user => (
|
||||||
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
|
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
|
||||||
<div style={{
|
<Avatar
|
||||||
width: 24, height: 24, borderRadius: '50%',
|
username={user.username}
|
||||||
backgroundColor: '#5865F2',
|
size={24}
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
style={{
|
||||||
marginRight: 8, fontSize: 10, color: 'white',
|
marginRight: 8,
|
||||||
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
|
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
|
||||||
}}>
|
}}
|
||||||
{user.username.substring(0, 1).toUpperCase()}
|
/>
|
||||||
</div>
|
<span style={{ color: 'var(--header-secondary)', fontSize: 14 }}>{user.username}</span>
|
||||||
<span style={{ color: '#b9bbbe', fontSize: 14 }}>{user.username}</span>
|
|
||||||
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center' }}>
|
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center' }}>
|
||||||
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
|
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
|
||||||
{(user.isMuted || user.isDeafened) && (
|
{(user.isMuted || user.isDeafened) && (
|
||||||
<ColoredIcon src={mutedIcon} color="#b9bbbe" size="14px" />
|
<ColoredIcon src={mutedIcon} color="var(--header-secondary)" size="14px" />
|
||||||
)}
|
)}
|
||||||
{user.isDeafened && (
|
{user.isDeafened && (
|
||||||
<ColoredIcon src={defeanedIcon} color="#b9bbbe" size="14px" />
|
<ColoredIcon src={defeanedIcon} color="var(--header-secondary)" size="14px" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -444,113 +487,129 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleCategory = (cat) => {
|
||||||
|
setCollapsedCategories(prev => ({ ...prev, [cat]: !prev[cat] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedChannels = React.useMemo(() => {
|
||||||
|
const groups = {};
|
||||||
|
channels.forEach(ch => {
|
||||||
|
const cat = ch.type === 'voice'
|
||||||
|
? (ch.category || 'Voice Channels')
|
||||||
|
: (ch.category || 'Text Channels');
|
||||||
|
if (!groups[cat]) groups[cat] = [];
|
||||||
|
groups[cat].push(ch);
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}, [channels]);
|
||||||
|
|
||||||
const renderServerView = () => (
|
const renderServerView = () => (
|
||||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
|
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||||
<div className="channel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div className="server-header" onClick={() => setIsServerSettingsOpen(true)}>
|
||||||
<span
|
<span>Secure Chat</span>
|
||||||
style={{ cursor: 'pointer', maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
<span className="server-header-chevron">▾</span>
|
||||||
onClick={() => setIsServerSettingsOpen(true)}
|
|
||||||
title="Server Settings"
|
|
||||||
>
|
|
||||||
Secure Chat ▾
|
|
||||||
</span>
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<button onClick={handleStartCreate} title="Create New Channel" style={{ ...headerButtonStyle, marginRight: '4px' }}>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
<button onClick={handleCreateInvite} title="Create Invite Link" style={headerButtonStyle}>
|
|
||||||
🔗
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isCreating && (
|
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
|
{isCreating && (
|
||||||
<form onSubmit={handleSubmitCreate}>
|
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
<form onSubmit={handleSubmitCreate}>
|
||||||
<label style={{ color: newChannelType==='text'?'white':'#b9bbbe', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('text')}>
|
<div style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||||||
Text
|
<label style={{ color: newChannelType==='text'?'white':'var(--interactive-normal)', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('text')}>
|
||||||
</label>
|
Text
|
||||||
<label style={{ color: newChannelType==='voice'?'white':'#b9bbbe', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('voice')}>
|
</label>
|
||||||
Voice
|
<label style={{ color: newChannelType==='voice'?'white':'var(--interactive-normal)', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('voice')}>
|
||||||
</label>
|
Voice
|
||||||
</div>
|
</label>
|
||||||
<input
|
</div>
|
||||||
autoFocus
|
<input
|
||||||
type="text"
|
autoFocus
|
||||||
placeholder={`new-${newChannelType}-channel`}
|
type="text"
|
||||||
value={newChannelName}
|
placeholder={`new-${newChannelType}-channel`}
|
||||||
onChange={(e) => setNewChannelName(e.target.value)}
|
value={newChannelName}
|
||||||
style={{
|
onChange={(e) => setNewChannelName(e.target.value)}
|
||||||
width: '100%',
|
style={{
|
||||||
background: '#202225',
|
width: '100%',
|
||||||
border: '1px solid #7289da',
|
background: 'var(--bg-tertiary)',
|
||||||
borderRadius: '4px',
|
border: '1px solid var(--brand-experiment)',
|
||||||
color: '#dcddde',
|
borderRadius: '4px',
|
||||||
padding: '4px 8px',
|
color: 'var(--text-normal)',
|
||||||
fontSize: '14px',
|
padding: '4px 8px',
|
||||||
outline: 'none'
|
fontSize: '14px',
|
||||||
}}
|
outline: 'none'
|
||||||
/>
|
}}
|
||||||
</form>
|
/>
|
||||||
<div style={{ fontSize: 10, color: '#b9bbbe', marginTop: 2, textAlign: 'right' }}>
|
</form>
|
||||||
Press Enter to Create {newChannelType === 'voice' && '(Voice)'}
|
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 2, textAlign: 'right' }}>
|
||||||
</div>
|
Press Enter to Create {newChannelType === 'voice' && '(Voice)'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{channels.map(channel => (
|
|
||||||
<React.Fragment key={channel._id}>
|
|
||||||
<div
|
|
||||||
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
|
|
||||||
onClick={() => handleChannelClick(channel)}
|
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingRight: '8px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden', flex: 1 }}>
|
|
||||||
{channel.type === 'voice' ? (
|
|
||||||
<div style={{ marginRight: 6 }}>
|
|
||||||
<ColoredIcon
|
|
||||||
src={voiceIcon}
|
|
||||||
size="16px"
|
|
||||||
color={voiceStates[channel._id]?.length > 0 ? VOICE_ACTIVE_COLOR : "#8e9297"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: '#8e9297', marginRight: '6px', flexShrink: 0 }}>#</span>
|
|
||||||
)}
|
|
||||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{channel.name}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="channel-settings-icon"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setEditingChannel(channel);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
color: '#b9bbbe',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '12px',
|
|
||||||
padding: '2px 4px',
|
|
||||||
display: 'flex', alignItems: 'center',
|
|
||||||
opacity: '0.7',
|
|
||||||
transition: 'opacity 0.2s'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
⚙️
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{renderVoiceUsers(channel)}
|
)}
|
||||||
</React.Fragment>
|
|
||||||
))}
|
{Object.entries(groupedChannels).map(([category, catChannels]) => (
|
||||||
|
<div key={category}>
|
||||||
|
<div className="channel-category-header" onClick={() => toggleCategory(category)}>
|
||||||
|
<span className={`category-chevron ${collapsedCategories[category] ? 'collapsed' : ''}`}>▾</span>
|
||||||
|
<span className="category-label">{category}</span>
|
||||||
|
<button className="category-add-btn" onClick={(e) => { e.stopPropagation(); handleStartCreate(); }} title="Create Channel">
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!collapsedCategories[category] && catChannels.map(channel => (
|
||||||
|
<React.Fragment key={channel._id}>
|
||||||
|
<div
|
||||||
|
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
|
||||||
|
onClick={() => handleChannelClick(channel)}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingRight: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden', flex: 1 }}>
|
||||||
|
{channel.type === 'voice' ? (
|
||||||
|
<div style={{ marginRight: 6 }}>
|
||||||
|
<ColoredIcon
|
||||||
|
src={voiceIcon}
|
||||||
|
size="16px"
|
||||||
|
color={voiceStates[channel._id]?.length > 0 ? VOICE_ACTIVE_COLOR : "var(--interactive-normal)"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--interactive-normal)', marginRight: '6px', flexShrink: 0 }}>#</span>
|
||||||
|
)}
|
||||||
|
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{channel.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="channel-settings-icon"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditingChannel(channel);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--interactive-normal)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
padding: '2px 4px',
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
opacity: '0',
|
||||||
|
transition: 'opacity 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚙️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{renderVoiceUsers(channel)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -558,26 +617,37 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
<div className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
|
<div className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||||
<div className="server-list">
|
<div className="server-list">
|
||||||
<div
|
<div className="server-item-wrapper">
|
||||||
className={`server-icon ${view === 'me' ? 'active' : ''}`}
|
<div className={`server-pill ${view === 'me' ? 'active' : ''}`} />
|
||||||
onClick={() => onViewChange('me')}
|
<Tooltip text="Direct Messages" position="right">
|
||||||
style={{
|
<div
|
||||||
backgroundColor: view === 'me' ? '#5865F2' : '#36393f',
|
className={`server-icon ${view === 'me' ? 'active' : ''}`}
|
||||||
color: view === 'me' ? '#fff' : '#dcddde',
|
onClick={() => onViewChange('me')}
|
||||||
marginBottom: '8px',
|
style={{
|
||||||
cursor: 'pointer'
|
backgroundColor: view === 'me' ? 'var(--brand-experiment)' : 'var(--bg-primary)',
|
||||||
}}
|
color: view === 'me' ? '#fff' : 'var(--text-normal)',
|
||||||
>
|
cursor: 'pointer'
|
||||||
<svg width="28" height="20" viewBox="0 0 28 20">
|
}}
|
||||||
<path fill="currentColor" d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.0172 1.11817 13.0907 1.11817 11.1372 1.4184C10.9331 0.934541 10.7018 0.461742 10.4496 0C8.59007 0.318797 6.78726 0.879656 5.0768 1.67671C1.65217 6.84883 0.706797 11.8997 1.166 16.858C3.39578 18.5135 5.56066 19.5165 7.6473 20.1417C8.16912 19.4246 8.63212 18.6713 9.02347 17.8863C8.25707 17.5929 7.52187 17.2415 6.82914 16.8374C7.0147 16.6975 7.19515 16.5518 7.36941 16.402C11.66 18.396 16.4523 18.396 20.7186 16.402C20.8953 16.5518 21.0758 16.6975 21.2613 16.8374C20.5663 17.2435 19.8288 17.5947 19.0624 17.8863C19.4537 18.6713 19.9167 19.4246 20.4385 20.1417C22.5276 19.5165 24.6925 18.5135 26.9223 16.858C27.4684 11.2365 25.9961 6.22055 23.0212 1.67671ZM9.68041 13.6383C8.39754 13.6383 7.34085 12.4456 7.34085 10.994C7.34085 9.5424 8.37555 8.34973 9.68041 8.34973C10.9893 8.34973 12.0395 9.5424 12.0175 10.994C12.0175 12.4456 10.9893 13.6383 9.68041 13.6383ZM18.3161 13.6383C17.0332 13.6383 15.9765 12.4456 15.9765 10.994C15.9765 9.5424 17.0112 8.34973 18.3161 8.34973C19.625 8.34973 20.6752 9.5424 20.6532 10.994C20.6532 12.4456 19.625 13.6383 18.3161 13.6383Z"/>
|
>
|
||||||
</svg>
|
<svg width="28" height="20" viewBox="0 0 28 20">
|
||||||
|
<path fill="currentColor" d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.0172 1.11817 13.0907 1.11817 11.1372 1.4184C10.9331 0.934541 10.7018 0.461742 10.4496 0C8.59007 0.318797 6.78726 0.879656 5.0768 1.67671C1.65217 6.84883 0.706797 11.8997 1.166 16.858C3.39578 18.5135 5.56066 19.5165 7.6473 20.1417C8.16912 19.4246 8.63212 18.6713 9.02347 17.8863C8.25707 17.5929 7.52187 17.2415 6.82914 16.8374C7.0147 16.6975 7.19515 16.5518 7.36941 16.402C11.66 18.396 16.4523 18.396 20.7186 16.402C20.8953 16.5518 21.0758 16.6975 21.2613 16.8374C20.5663 17.2435 19.8288 17.5947 19.0624 17.8863C19.4537 18.6713 19.9167 19.4246 20.4385 20.1417C22.5276 19.5165 24.6925 18.5135 26.9223 16.858C27.4684 11.2365 25.9961 6.22055 23.0212 1.67671ZM9.68041 13.6383C8.39754 13.6383 7.34085 12.4456 7.34085 10.994C7.34085 9.5424 8.37555 8.34973 9.68041 8.34973C10.9893 8.34973 12.0395 9.5424 12.0175 10.994C12.0175 12.4456 10.9893 13.6383 9.68041 13.6383ZM18.3161 13.6383C17.0332 13.6383 15.9765 12.4456 15.9765 10.994C15.9765 9.5424 17.0112 8.34973 18.3161 8.34973C19.625 8.34973 20.6752 9.5424 20.6532 10.994C20.6532 12.4456 19.625 13.6383 18.3161 13.6383Z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="server-separator" />
|
||||||
className={`server-icon ${view === 'server' ? 'active' : ''}`}
|
|
||||||
onClick={() => onViewChange('server')}
|
<div className="server-item-wrapper">
|
||||||
style={{ cursor: 'pointer' }}
|
<div className={`server-pill ${view === 'server' ? 'active' : ''}`} />
|
||||||
>Sc</div>
|
<Tooltip text="Secure Chat" position="right">
|
||||||
|
<div
|
||||||
|
className={`server-icon ${view === 'server' ? 'active' : ''}`}
|
||||||
|
onClick={() => onViewChange('server')}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>Sc</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === 'me' ? renderDMView() : renderServerView()}
|
{view === 'me' ? renderDMView() : renderServerView()}
|
||||||
@@ -585,7 +655,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
|
|
||||||
{connectionState === 'connected' && (
|
{connectionState === 'connected' && (
|
||||||
<div style={{
|
<div style={{
|
||||||
backgroundColor: '#292b2f',
|
backgroundColor: 'var(--panel-bg)',
|
||||||
borderRadius: '8px 8px 0px 0px',
|
borderRadius: '8px 8px 0px 0px',
|
||||||
padding: 'calc(16px - 8px + 4px)',
|
padding: 'calc(16px - 8px + 4px)',
|
||||||
margin: '8px 8px 0px 8px',
|
margin: '8px 8px 0px 8px',
|
||||||
@@ -602,22 +672,23 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
background: 'transparent', border: 'none', cursor: 'pointer', padding: '0', display: 'flex', justifyContent: 'center'
|
background: 'transparent', border: 'none', cursor: 'pointer', padding: '0', display: 'flex', justifyContent: 'center'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ColoredIcon src={disconnectIcon} color="#b9bbbe" size="20px" />
|
<ColoredIcon src={disconnectIcon} color="var(--header-secondary)" size="20px" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#dcddde', fontSize: 12, marginBottom: 8 }}>{voiceChannelName} / Secure Chat</div>
|
<div style={{ color: 'var(--text-normal)', fontSize: 12, marginBottom: 4 }}>{voiceChannelName} / Secure Chat</div>
|
||||||
|
<div style={{ marginBottom: 8 }}><VoiceTimer /></div>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
<button onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)} title="Turn On Camera" style={voicePanelButtonStyle}>
|
<button onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)} title="Turn On Camera" style={voicePanelButtonStyle}>
|
||||||
<ColoredIcon src={cameraIcon} color="#b9bbbe" size="20px" />
|
<ColoredIcon src={cameraIcon} color="var(--header-secondary)" size="20px" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleScreenShareClick} title="Share Screen" style={voicePanelButtonStyle}>
|
<button onClick={handleScreenShareClick} title="Share Screen" style={voicePanelButtonStyle}>
|
||||||
<ColoredIcon src={screenIcon} color="#b9bbbe" size="20px" />
|
<ColoredIcon src={screenIcon} color="var(--header-secondary)" size="20px" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<UserControlPanel username={username} />
|
<UserControlPanel username={username} userId={userId} />
|
||||||
|
|
||||||
{editingChannel && (
|
{editingChannel && (
|
||||||
<ChannelSettingsModal
|
<ChannelSettingsModal
|
||||||
|
|||||||
56
Frontend/Electron/src/components/ThemeSelector.jsx
Normal file
56
Frontend/Electron/src/components/ThemeSelector.jsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTheme, THEMES, THEME_LABELS } from '../contexts/ThemeContext';
|
||||||
|
|
||||||
|
const THEME_PREVIEWS = {
|
||||||
|
[THEMES.LIGHT]: { bg: '#ffffff', sidebar: '#f2f3f5', tertiary: '#e3e5e8', text: '#313338' },
|
||||||
|
[THEMES.DARK]: { bg: '#313338', sidebar: '#2b2d31', tertiary: '#1e1f22', text: '#f2f3f5' },
|
||||||
|
[THEMES.ASH]: { bg: '#202225', sidebar: '#1a1b1e', tertiary: '#111214', text: '#f0f1f3' },
|
||||||
|
[THEMES.ONYX]: { bg: '#0c0c14', sidebar: '#080810', tertiary: '#000000', text: '#e0def0' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeSelector = ({ onClose }) => {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="theme-selector-overlay" onClick={onClose}>
|
||||||
|
<div className="theme-selector-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="theme-selector-header">
|
||||||
|
<h2>Appearance</h2>
|
||||||
|
<button className="theme-selector-close" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="theme-selector-grid">
|
||||||
|
{Object.values(THEMES).map((themeKey) => {
|
||||||
|
const preview = THEME_PREVIEWS[themeKey];
|
||||||
|
const isActive = theme === themeKey;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={themeKey}
|
||||||
|
className={`theme-card ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={() => setTheme(themeKey)}
|
||||||
|
>
|
||||||
|
<div className="theme-preview" style={{ backgroundColor: preview.bg }}>
|
||||||
|
<div className="theme-preview-sidebar" style={{ backgroundColor: preview.sidebar }}>
|
||||||
|
<div className="theme-preview-channel" style={{ backgroundColor: preview.tertiary }} />
|
||||||
|
<div className="theme-preview-channel" style={{ backgroundColor: preview.tertiary, width: '60%' }} />
|
||||||
|
</div>
|
||||||
|
<div className="theme-preview-chat">
|
||||||
|
<div className="theme-preview-message" style={{ backgroundColor: preview.sidebar, opacity: 0.6 }} />
|
||||||
|
<div className="theme-preview-message" style={{ backgroundColor: preview.sidebar, opacity: 0.4, width: '70%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="theme-card-label">
|
||||||
|
<div className={`theme-radio ${isActive ? 'active' : ''}`}>
|
||||||
|
{isActive && <div className="theme-radio-dot" />}
|
||||||
|
</div>
|
||||||
|
<span>{THEME_LABELS[themeKey]}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeSelector;
|
||||||
61
Frontend/Electron/src/components/TitleBar.jsx
Normal file
61
Frontend/Electron/src/components/TitleBar.jsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const TitleBar = () => {
|
||||||
|
return (
|
||||||
|
<div className="titlebar">
|
||||||
|
<div className="titlebar-drag-region" />
|
||||||
|
<div className="titlebar-nav">
|
||||||
|
<button
|
||||||
|
className="titlebar-nav-btn"
|
||||||
|
onClick={() => window.history.back()}
|
||||||
|
aria-label="Go Back"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="titlebar-nav-btn"
|
||||||
|
onClick={() => window.history.forward()}
|
||||||
|
aria-label="Go Forward"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="titlebar-title">Discord Clone</div>
|
||||||
|
<div className="titlebar-buttons">
|
||||||
|
<button
|
||||||
|
className="titlebar-btn titlebar-minimize"
|
||||||
|
onClick={() => window.windowControls?.minimize()}
|
||||||
|
aria-label="Minimize"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||||
|
<rect fill="currentColor" width="10" height="1" x="1" y="6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="titlebar-btn titlebar-maximize"
|
||||||
|
onClick={() => window.windowControls?.maximize()}
|
||||||
|
aria-label="Maximize"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||||
|
<rect fill="none" stroke="currentColor" strokeWidth="1" width="8" height="8" x="2" y="2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="titlebar-btn titlebar-close"
|
||||||
|
onClick={() => window.windowControls?.close()}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||||
|
<polygon fill="currentColor" points="11,1.576 10.424,1 6,5.424 1.576,1 1,1.576 5.424,6 1,10.424 1.576,11 6,6.576 10.424,11 11,10.424 6.576,6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TitleBar;
|
||||||
69
Frontend/Electron/src/components/Toast.jsx
Normal file
69
Frontend/Electron/src/components/Toast.jsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
|
||||||
|
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||||
|
|
||||||
|
function getUserColor(name) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContainer = ({ toasts, removeToast }) => {
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<div className="toast-container">
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<ToastItem key={toast.id} toast={toast} onDismiss={() => removeToast(toast.id)} />
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToastItem = ({ toast, onDismiss }) => {
|
||||||
|
const [exiting, setExiting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setExiting(true);
|
||||||
|
setTimeout(onDismiss, 300);
|
||||||
|
}, 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [onDismiss]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`toast ${exiting ? 'toast-exit' : 'toast-enter'}`}>
|
||||||
|
<div className="toast-avatar" style={{ backgroundColor: getUserColor(toast.username || '') }}>
|
||||||
|
{(toast.username || '?').substring(0, 1).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="toast-body">
|
||||||
|
<div className="toast-title">New message from <strong>{toast.username}</strong></div>
|
||||||
|
<div className="toast-preview">{toast.preview}</div>
|
||||||
|
</div>
|
||||||
|
<button className="toast-close" onClick={() => { setExiting(true); setTimeout(onDismiss, 300); }}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useToasts() {
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
|
||||||
|
const addToast = useCallback((toast) => {
|
||||||
|
const id = Date.now() + Math.random();
|
||||||
|
setToasts(prev => [...prev.slice(-4), { ...toast, id }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id) => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { toasts, addToast, removeToast, ToastContainer: () => <ToastContainer toasts={toasts} removeToast={removeToast} /> };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ToastContainer;
|
||||||
90
Frontend/Electron/src/components/Tooltip.jsx
Normal file
90
Frontend/Electron/src/components/Tooltip.jsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
|
||||||
|
const Tooltip = ({ children, text, position = 'top' }) => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
||||||
|
const triggerRef = useRef(null);
|
||||||
|
const timeoutRef = useRef(null);
|
||||||
|
|
||||||
|
const showTooltip = () => {
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
if (triggerRef.current) {
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
|
let top, left;
|
||||||
|
|
||||||
|
switch (position) {
|
||||||
|
case 'bottom':
|
||||||
|
top = rect.bottom + 8;
|
||||||
|
left = rect.left + rect.width / 2;
|
||||||
|
break;
|
||||||
|
case 'left':
|
||||||
|
top = rect.top + rect.height / 2;
|
||||||
|
left = rect.left - 8;
|
||||||
|
break;
|
||||||
|
case 'right':
|
||||||
|
top = rect.top + rect.height / 2;
|
||||||
|
left = rect.right + 8;
|
||||||
|
break;
|
||||||
|
default: // top
|
||||||
|
top = rect.top - 8;
|
||||||
|
left = rect.left + rect.width / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setCoords({ top, left });
|
||||||
|
setVisible(true);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideTooltip = () => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
setVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getTransformStyle = () => {
|
||||||
|
switch (position) {
|
||||||
|
case 'bottom': return 'translate(-50%, 0)';
|
||||||
|
case 'left': return 'translate(-100%, -50%)';
|
||||||
|
case 'right': return 'translate(0, -50%)';
|
||||||
|
default: return 'translate(-50%, -100%)';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getArrowClass = () => `tooltip-arrow tooltip-arrow-${position}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={triggerRef}
|
||||||
|
onMouseEnter={showTooltip}
|
||||||
|
onMouseLeave={hideTooltip}
|
||||||
|
style={{ display: 'inline-flex' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{visible && ReactDOM.createPortal(
|
||||||
|
<div
|
||||||
|
className="tooltip"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: coords.top,
|
||||||
|
left: coords.left,
|
||||||
|
transform: getTransformStyle(),
|
||||||
|
zIndex: 10001,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
<div className={getArrowClass()} />
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Tooltip;
|
||||||
133
Frontend/Electron/src/components/UserProfilePopup.jsx
Normal file
133
Frontend/Electron/src/components/UserProfilePopup.jsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
|
import { api } from '../../../../convex/_generated/api';
|
||||||
|
import Avatar from './Avatar';
|
||||||
|
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
online: 'Online',
|
||||||
|
idle: 'Idle',
|
||||||
|
dnd: 'Do Not Disturb',
|
||||||
|
invisible: 'Invisible',
|
||||||
|
offline: 'Offline',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
online: '#3ba55c',
|
||||||
|
idle: '#faa61a',
|
||||||
|
dnd: '#ed4245',
|
||||||
|
invisible: '#747f8d',
|
||||||
|
offline: '#747f8d',
|
||||||
|
};
|
||||||
|
|
||||||
|
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||||
|
|
||||||
|
function getUserColor(name) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserProfilePopup = ({ userId, username, avatarUrl, status, position, onClose, onSendMessage }) => {
|
||||||
|
const popupRef = useRef(null);
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
|
||||||
|
// Fetch member data (roles, aboutMe) for this user
|
||||||
|
const allUsers = useQuery(api.auth.getPublicKeys) || [];
|
||||||
|
const userData = allUsers.find(u => u.id === userId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = (e) => {
|
||||||
|
if (popupRef.current && !popupRef.current.contains(e.target)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Load note from localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
if (userId) {
|
||||||
|
const saved = localStorage.getItem(`note_${userId}`);
|
||||||
|
if (saved) setNote(saved);
|
||||||
|
}
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
const handleNoteChange = (e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setNote(val);
|
||||||
|
if (userId) {
|
||||||
|
localStorage.setItem(`note_${userId}`, val);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const userColor = getUserColor(username || 'Unknown');
|
||||||
|
const userStatus = status || 'online';
|
||||||
|
const resolvedAvatarUrl = avatarUrl || userData?.avatarUrl;
|
||||||
|
const aboutMe = userData?.aboutMe;
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
position: 'fixed',
|
||||||
|
top: Math.min(position.y, window.innerHeight - 420),
|
||||||
|
left: Math.min(position.x, window.innerWidth - 320),
|
||||||
|
zIndex: 10000,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<div ref={popupRef} className="user-profile-popup" style={style}>
|
||||||
|
<div className="user-profile-banner" style={{ backgroundColor: userColor }} />
|
||||||
|
<div className="user-profile-body">
|
||||||
|
<div className="user-profile-avatar-wrapper">
|
||||||
|
<Avatar
|
||||||
|
username={username}
|
||||||
|
avatarUrl={resolvedAvatarUrl}
|
||||||
|
size={64}
|
||||||
|
className="user-profile-avatar"
|
||||||
|
style={{ border: '4px solid var(--background-base-lowest)' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="user-profile-status-dot"
|
||||||
|
style={{ backgroundColor: STATUS_COLORS[userStatus] }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="user-profile-name">{username}</div>
|
||||||
|
<div className="user-profile-status-text">
|
||||||
|
{userData?.customStatus || STATUS_LABELS[userStatus] || 'Online'}
|
||||||
|
</div>
|
||||||
|
<div className="user-profile-divider" />
|
||||||
|
|
||||||
|
<div className="user-profile-section-header">ABOUT ME</div>
|
||||||
|
<div className="user-profile-about">
|
||||||
|
{aboutMe || 'No information set.'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="user-profile-divider" />
|
||||||
|
|
||||||
|
<div className="user-profile-section-header">NOTE</div>
|
||||||
|
<textarea
|
||||||
|
className="user-profile-note-input"
|
||||||
|
placeholder="Click to add a note"
|
||||||
|
value={note}
|
||||||
|
onChange={handleNoteChange}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{onSendMessage && (
|
||||||
|
<button
|
||||||
|
className="user-profile-message-btn"
|
||||||
|
onClick={() => { onSendMessage(userId, username); onClose(); }}
|
||||||
|
style={{ marginTop: '8px' }}
|
||||||
|
>
|
||||||
|
Send Message
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserProfilePopup;
|
||||||
42
Frontend/Electron/src/contexts/ThemeContext.jsx
Normal file
42
Frontend/Electron/src/contexts/ThemeContext.jsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'discord-theme';
|
||||||
|
|
||||||
|
export const THEMES = {
|
||||||
|
LIGHT: 'theme-light',
|
||||||
|
DARK: 'theme-dark',
|
||||||
|
ASH: 'theme-darker',
|
||||||
|
ONYX: 'theme-midnight',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const THEME_LABELS = {
|
||||||
|
[THEMES.LIGHT]: 'Light',
|
||||||
|
[THEMES.DARK]: 'Dark',
|
||||||
|
[THEMES.ASH]: 'Ash',
|
||||||
|
[THEMES.ONYX]: 'Onyx',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeContext = createContext();
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }) {
|
||||||
|
const [theme, setTheme] = useState(() => {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) || THEMES.DARK;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.className = theme;
|
||||||
|
localStorage.setItem(STORAGE_KEY, theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, setTheme, THEMES, THEME_LABELS }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,20 +3,26 @@ import ReactDOM from 'react-dom/client';
|
|||||||
import { HashRouter } from 'react-router-dom';
|
import { HashRouter } from 'react-router-dom';
|
||||||
import { ConvexProvider, ConvexReactClient } from 'convex/react';
|
import { ConvexProvider, ConvexReactClient } from 'convex/react';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import './styles/themes.css';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
import { VoiceProvider } from './contexts/VoiceContext';
|
import { VoiceProvider } from './contexts/VoiceContext';
|
||||||
|
import TitleBar from './components/TitleBar';
|
||||||
|
|
||||||
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
|
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ConvexProvider client={convex}>
|
<ThemeProvider>
|
||||||
<VoiceProvider>
|
<ConvexProvider client={convex}>
|
||||||
<HashRouter>
|
<VoiceProvider>
|
||||||
<App />
|
<TitleBar />
|
||||||
</HashRouter>
|
<HashRouter>
|
||||||
</VoiceProvider>
|
<App />
|
||||||
</ConvexProvider>
|
</HashRouter>
|
||||||
|
</VoiceProvider>
|
||||||
|
</ConvexProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useQuery, useConvex } from 'convex/react';
|
import { useQuery, useConvex } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
import Sidebar from '../components/Sidebar';
|
import Sidebar from '../components/Sidebar';
|
||||||
@@ -6,6 +6,9 @@ import ChatArea from '../components/ChatArea';
|
|||||||
import VoiceStage from '../components/VoiceStage';
|
import VoiceStage from '../components/VoiceStage';
|
||||||
import { useVoice } from '../contexts/VoiceContext';
|
import { useVoice } from '../contexts/VoiceContext';
|
||||||
import FriendsView from '../components/FriendsView';
|
import FriendsView from '../components/FriendsView';
|
||||||
|
import MembersList from '../components/MembersList';
|
||||||
|
import ChatHeader from '../components/ChatHeader';
|
||||||
|
import { useToasts } from '../components/Toast';
|
||||||
|
|
||||||
const Chat = () => {
|
const Chat = () => {
|
||||||
const [view, setView] = useState('server');
|
const [view, setView] = useState('server');
|
||||||
@@ -14,8 +17,29 @@ const Chat = () => {
|
|||||||
const [userId, setUserId] = useState(null);
|
const [userId, setUserId] = useState(null);
|
||||||
const [channelKeys, setChannelKeys] = useState({});
|
const [channelKeys, setChannelKeys] = useState({});
|
||||||
const [activeDMChannel, setActiveDMChannel] = useState(null);
|
const [activeDMChannel, setActiveDMChannel] = useState(null);
|
||||||
|
const [showMembers, setShowMembers] = useState(true);
|
||||||
|
const [showPinned, setShowPinned] = useState(false);
|
||||||
|
|
||||||
const convex = useConvex();
|
const convex = useConvex();
|
||||||
|
const { toasts, addToast, removeToast, ToastContainer } = useToasts();
|
||||||
|
const prevDmChannelsRef = useRef(null);
|
||||||
|
const { toggleMute } = useVoice();
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => {
|
||||||
|
if (e.ctrlKey && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
// Quick switcher placeholder - could open a search modal
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key === 'M') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleMute();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [toggleMute]);
|
||||||
|
|
||||||
const channels = useQuery(api.channels.list) || [];
|
const channels = useQuery(api.channels.list) || [];
|
||||||
|
|
||||||
@@ -113,20 +137,46 @@ const Chat = () => {
|
|||||||
}
|
}
|
||||||
}, [convex]);
|
}, [convex]);
|
||||||
|
|
||||||
|
const handleSelectChannel = useCallback((channelId) => {
|
||||||
|
setActiveChannel(channelId);
|
||||||
|
setShowPinned(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const activeChannelObj = channels.find(c => c._id === activeChannel);
|
const activeChannelObj = channels.find(c => c._id === activeChannel);
|
||||||
const { room, voiceStates } = useVoice();
|
const { room, voiceStates } = useVoice();
|
||||||
|
|
||||||
|
const isDMView = view === 'me' && activeDMChannel;
|
||||||
|
const isServerTextChannel = view === 'server' && activeChannel && activeChannelObj?.type !== 'voice';
|
||||||
|
const currentChannelId = isDMView ? activeDMChannel.channel_id : activeChannel;
|
||||||
|
|
||||||
function renderMainContent() {
|
function renderMainContent() {
|
||||||
if (view === 'me') {
|
if (view === 'me') {
|
||||||
if (activeDMChannel) {
|
if (activeDMChannel) {
|
||||||
return (
|
return (
|
||||||
<ChatArea
|
<div className="chat-container">
|
||||||
channelId={activeDMChannel.channel_id}
|
<ChatHeader
|
||||||
channelName={activeDMChannel.other_username}
|
channelName={activeDMChannel.other_username}
|
||||||
channelKey={channelKeys[activeDMChannel.channel_id]}
|
channelType="dm"
|
||||||
username={username}
|
onToggleMembers={() => {}}
|
||||||
userId={userId}
|
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} />;
|
return <FriendsView onOpenDM={openDM} />;
|
||||||
@@ -137,13 +187,37 @@ const Chat = () => {
|
|||||||
return <VoiceStage room={room} channelId={activeChannel} voiceStates={voiceStates} channelName={activeChannelObj?.name} />;
|
return <VoiceStage room={room} channelId={activeChannel} voiceStates={voiceStates} channelName={activeChannelObj?.name} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ChatArea
|
<div className="chat-container">
|
||||||
channelId={activeChannel}
|
<ChatHeader
|
||||||
channelName={activeChannelObj?.name || activeChannel}
|
channelName={activeChannelObj?.name || activeChannel}
|
||||||
channelKey={channelKeys[activeChannel]}
|
channelType="text"
|
||||||
username={username}
|
channelTopic={activeChannelObj?.topic}
|
||||||
userId={userId}
|
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
|
<Sidebar
|
||||||
channels={channels}
|
channels={channels}
|
||||||
activeChannel={activeChannel}
|
activeChannel={activeChannel}
|
||||||
onSelectChannel={setActiveChannel}
|
onSelectChannel={handleSelectChannel}
|
||||||
username={username}
|
username={username}
|
||||||
channelKeys={channelKeys}
|
channelKeys={channelKeys}
|
||||||
view={view}
|
view={view}
|
||||||
@@ -170,8 +244,10 @@ const Chat = () => {
|
|||||||
activeDMChannel={activeDMChannel}
|
activeDMChannel={activeDMChannel}
|
||||||
setActiveDMChannel={setActiveDMChannel}
|
setActiveDMChannel={setActiveDMChannel}
|
||||||
dmChannels={dmChannels}
|
dmChannels={dmChannels}
|
||||||
|
userId={userId}
|
||||||
/>
|
/>
|
||||||
{renderMainContent()}
|
{renderMainContent()}
|
||||||
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
360
Frontend/Electron/src/styles/themes.css
Normal file
360
Frontend/Electron/src/styles/themes.css
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
/* ============================================
|
||||||
|
Discord Theme System
|
||||||
|
4 themes: Light, Dark (default), Ash, Onyx
|
||||||
|
CSS class mapping:
|
||||||
|
Light → .theme-light
|
||||||
|
Dark → .theme-dark
|
||||||
|
Ash → .theme-darker
|
||||||
|
Onyx → .theme-midnight
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DARK THEME (default)
|
||||||
|
============================================ */
|
||||||
|
.theme-dark {
|
||||||
|
/* Backgrounds */
|
||||||
|
--background-base-low: #313338;
|
||||||
|
--background-base-lower: #2b2d31;
|
||||||
|
--background-base-lowest: #1e1f22;
|
||||||
|
--background-surface-high: #3c3e44;
|
||||||
|
--background-surface-higher: #3f4147;
|
||||||
|
--background-surface-highest: #43454b;
|
||||||
|
--chat-background: #313338;
|
||||||
|
--channeltextarea-background: #383a40;
|
||||||
|
--modal-background: #313338;
|
||||||
|
--panel-bg: #2b2d31;
|
||||||
|
--embed-background: #2f3136;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-default: #f2f3f5;
|
||||||
|
--text-strong: #f2f3f5;
|
||||||
|
--text-muted: #949ba4;
|
||||||
|
--text-subtle: #b5bac1;
|
||||||
|
--text-link: #00a8fc;
|
||||||
|
--channels-default: #949ba4;
|
||||||
|
--text-feedback-critical: #ed4245;
|
||||||
|
|
||||||
|
/* Interactive */
|
||||||
|
--interactive-background-hover: rgba(78, 80, 88, 0.3);
|
||||||
|
--interactive-background-active: rgba(78, 80, 88, 0.48);
|
||||||
|
--interactive-background-selected: rgba(78, 80, 88, 0.6);
|
||||||
|
--interactive-icon-default: #b5bac1;
|
||||||
|
--interactive-icon-hover: #dbdee1;
|
||||||
|
--interactive-icon-active: #ffffff;
|
||||||
|
--interactive-text-default: #b5bac1;
|
||||||
|
--interactive-text-hover: #dbdee1;
|
||||||
|
--interactive-text-active: #ffffff;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border-subtle: #1e1f22;
|
||||||
|
--border-muted: rgba(255, 255, 255, 0.04);
|
||||||
|
--border-normal: rgba(255, 255, 255, 0.2);
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.44);
|
||||||
|
|
||||||
|
/* Icons */
|
||||||
|
--icon-default: #dbdee1;
|
||||||
|
--icon-strong: #ffffff;
|
||||||
|
--icon-muted: #949ba4;
|
||||||
|
--icon-subtle: #b5bac1;
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
|
--control-primary-background-default: #5865f2;
|
||||||
|
--control-primary-background-hover: #4752c4;
|
||||||
|
--control-primary-background-active: #3b43a8;
|
||||||
|
--control-critical-primary-background-default: #ed4245;
|
||||||
|
|
||||||
|
/* Input */
|
||||||
|
--input-background-default: #383a40;
|
||||||
|
--input-border-default: rgba(255, 255, 255, 0.2);
|
||||||
|
--input-text-default: #dbdee1;
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
--scrollbar-auto-thumb: #1a1b1e;
|
||||||
|
--scrollbar-thin-thumb: #1a1b1e;
|
||||||
|
|
||||||
|
/* Message */
|
||||||
|
--message-background-hover: rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
/* Compatibility aliases (map old names → new semantic names) */
|
||||||
|
--bg-primary: #313338;
|
||||||
|
--bg-secondary: #2b2d31;
|
||||||
|
--bg-tertiary: #1e1f22;
|
||||||
|
--text-normal: #dbdee1;
|
||||||
|
--header-primary: #f2f3f5;
|
||||||
|
--header-secondary: #b5bac1;
|
||||||
|
--interactive-normal: #b5bac1;
|
||||||
|
--interactive-hover: #dbdee1;
|
||||||
|
--interactive-active: #ffffff;
|
||||||
|
--brand-experiment: #5865f2;
|
||||||
|
--brand-experiment-hover: #4752c4;
|
||||||
|
--input-background: #383a40;
|
||||||
|
--danger: #ed4245;
|
||||||
|
--background-modifier-hover: rgba(78, 80, 88, 0.3);
|
||||||
|
--background-modifier-active: rgba(78, 80, 88, 0.48);
|
||||||
|
--background-modifier-selected: rgba(78, 80, 88, 0.6);
|
||||||
|
--div-border: #1e1f22;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
LIGHT THEME
|
||||||
|
============================================ */
|
||||||
|
.theme-light {
|
||||||
|
/* Backgrounds */
|
||||||
|
--background-base-low: #ffffff;
|
||||||
|
--background-base-lower: #f2f3f5;
|
||||||
|
--background-base-lowest: #e3e5e8;
|
||||||
|
--background-surface-high: #ffffff;
|
||||||
|
--background-surface-higher: #ffffff;
|
||||||
|
--background-surface-highest: #ffffff;
|
||||||
|
--chat-background: #ffffff;
|
||||||
|
--channeltextarea-background: #ebedef;
|
||||||
|
--modal-background: #ffffff;
|
||||||
|
--panel-bg: #f2f3f5;
|
||||||
|
--embed-background: #f2f3f5;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-default: #313338;
|
||||||
|
--text-strong: #060607;
|
||||||
|
--text-muted: #5c6470;
|
||||||
|
--text-subtle: #4e5058;
|
||||||
|
--text-link: #006ce7;
|
||||||
|
--channels-default: #5c6470;
|
||||||
|
--text-feedback-critical: #da373c;
|
||||||
|
|
||||||
|
/* Interactive */
|
||||||
|
--interactive-background-hover: rgba(116, 124, 138, 0.14);
|
||||||
|
--interactive-background-active: rgba(116, 124, 138, 0.22);
|
||||||
|
--interactive-background-selected: rgba(116, 124, 138, 0.30);
|
||||||
|
--interactive-icon-default: #4e5058;
|
||||||
|
--interactive-icon-hover: #313338;
|
||||||
|
--interactive-icon-active: #060607;
|
||||||
|
--interactive-text-default: #4e5058;
|
||||||
|
--interactive-text-hover: #313338;
|
||||||
|
--interactive-text-active: #060607;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border-subtle: rgba(0, 0, 0, 0.28);
|
||||||
|
--border-muted: rgba(0, 0, 0, 0.2);
|
||||||
|
--border-normal: rgba(0, 0, 0, 0.36);
|
||||||
|
--border-strong: rgba(0, 0, 0, 0.48);
|
||||||
|
|
||||||
|
/* Icons */
|
||||||
|
--icon-default: #313338;
|
||||||
|
--icon-strong: #060607;
|
||||||
|
--icon-muted: #5c6470;
|
||||||
|
--icon-subtle: #4e5058;
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
|
--control-primary-background-default: #5865f2;
|
||||||
|
--control-primary-background-hover: #4752c4;
|
||||||
|
--control-primary-background-active: #3b43a8;
|
||||||
|
--control-critical-primary-background-default: #da373c;
|
||||||
|
|
||||||
|
/* Input */
|
||||||
|
--input-background-default: #e3e5e8;
|
||||||
|
--input-border-default: rgba(0, 0, 0, 0.36);
|
||||||
|
--input-text-default: #313338;
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
--scrollbar-auto-thumb: #c1c3c7;
|
||||||
|
--scrollbar-thin-thumb: #c1c3c7;
|
||||||
|
|
||||||
|
/* Message */
|
||||||
|
--message-background-hover: rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
/* Compatibility aliases */
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: #f2f3f5;
|
||||||
|
--bg-tertiary: #e3e5e8;
|
||||||
|
--text-normal: #313338;
|
||||||
|
--header-primary: #060607;
|
||||||
|
--header-secondary: #4e5058;
|
||||||
|
--interactive-normal: #4e5058;
|
||||||
|
--interactive-hover: #313338;
|
||||||
|
--interactive-active: #060607;
|
||||||
|
--brand-experiment: #5865f2;
|
||||||
|
--brand-experiment-hover: #4752c4;
|
||||||
|
--input-background: #e3e5e8;
|
||||||
|
--danger: #da373c;
|
||||||
|
--background-modifier-hover: rgba(116, 124, 138, 0.14);
|
||||||
|
--background-modifier-active: rgba(116, 124, 138, 0.22);
|
||||||
|
--background-modifier-selected: rgba(116, 124, 138, 0.30);
|
||||||
|
--div-border: #e1e2e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ASH THEME (theme-darker)
|
||||||
|
============================================ */
|
||||||
|
.theme-darker {
|
||||||
|
/* Backgrounds */
|
||||||
|
--background-base-low: #202225;
|
||||||
|
--background-base-lower: #1a1b1e;
|
||||||
|
--background-base-lowest: #111214;
|
||||||
|
--background-surface-high: #292b2f;
|
||||||
|
--background-surface-higher: #2e3035;
|
||||||
|
--background-surface-highest: #33363c;
|
||||||
|
--chat-background: #202225;
|
||||||
|
--channeltextarea-background: #252529;
|
||||||
|
--modal-background: #292b2f;
|
||||||
|
--panel-bg: #1a1b1e;
|
||||||
|
--embed-background: #242529;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-default: #f0f1f3;
|
||||||
|
--text-strong: #f5f5f7;
|
||||||
|
--text-muted: #858993;
|
||||||
|
--text-subtle: #a0a4ad;
|
||||||
|
--text-link: #00a8fc;
|
||||||
|
--channels-default: #858993;
|
||||||
|
--text-feedback-critical: #ed4245;
|
||||||
|
|
||||||
|
/* Interactive */
|
||||||
|
--interactive-background-hover: rgba(78, 80, 88, 0.15);
|
||||||
|
--interactive-background-active: rgba(78, 80, 88, 0.3);
|
||||||
|
--interactive-background-selected: rgba(78, 80, 88, 0.4);
|
||||||
|
--interactive-icon-default: #a0a4ad;
|
||||||
|
--interactive-icon-hover: #dddfe4;
|
||||||
|
--interactive-icon-active: #f5f5f7;
|
||||||
|
--interactive-text-default: #a0a4ad;
|
||||||
|
--interactive-text-hover: #dddfe4;
|
||||||
|
--interactive-text-active: #f5f5f7;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border-subtle: rgba(255, 255, 255, 0.12);
|
||||||
|
--border-muted: rgba(255, 255, 255, 0.04);
|
||||||
|
--border-normal: rgba(255, 255, 255, 0.2);
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.44);
|
||||||
|
|
||||||
|
/* Icons */
|
||||||
|
--icon-default: #dddfe4;
|
||||||
|
--icon-strong: #f5f5f7;
|
||||||
|
--icon-muted: #858993;
|
||||||
|
--icon-subtle: #a0a4ad;
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
|
--control-primary-background-default: #5865f2;
|
||||||
|
--control-primary-background-hover: #4752c4;
|
||||||
|
--control-primary-background-active: #3b43a8;
|
||||||
|
--control-critical-primary-background-default: #ed4245;
|
||||||
|
|
||||||
|
/* Input */
|
||||||
|
--input-background-default: #252529;
|
||||||
|
--input-border-default: rgba(255, 255, 255, 0.2);
|
||||||
|
--input-text-default: #dddfe4;
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
--scrollbar-auto-thumb: #15161a;
|
||||||
|
--scrollbar-thin-thumb: #15161a;
|
||||||
|
|
||||||
|
/* Message */
|
||||||
|
--message-background-hover: rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
/* Compatibility aliases */
|
||||||
|
--bg-primary: #202225;
|
||||||
|
--bg-secondary: #1a1b1e;
|
||||||
|
--bg-tertiary: #111214;
|
||||||
|
--text-normal: #dddfe4;
|
||||||
|
--header-primary: #f5f5f7;
|
||||||
|
--header-secondary: #a0a4ad;
|
||||||
|
--interactive-normal: #a0a4ad;
|
||||||
|
--interactive-hover: #dddfe4;
|
||||||
|
--interactive-active: #f5f5f7;
|
||||||
|
--brand-experiment: #5865f2;
|
||||||
|
--brand-experiment-hover: #4752c4;
|
||||||
|
--input-background: #252529;
|
||||||
|
--danger: #ed4245;
|
||||||
|
--background-modifier-hover: rgba(78, 80, 88, 0.15);
|
||||||
|
--background-modifier-active: rgba(78, 80, 88, 0.3);
|
||||||
|
--background-modifier-selected: rgba(78, 80, 88, 0.4);
|
||||||
|
--div-border: #111214;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
ONYX THEME (theme-midnight)
|
||||||
|
============================================ */
|
||||||
|
.theme-midnight {
|
||||||
|
/* Backgrounds */
|
||||||
|
--background-base-low: #0c0c14;
|
||||||
|
--background-base-lower: #080810;
|
||||||
|
--background-base-lowest: #000000;
|
||||||
|
--background-surface-high: #141422;
|
||||||
|
--background-surface-higher: #1a1a2e;
|
||||||
|
--background-surface-highest: #202038;
|
||||||
|
--chat-background: #000000;
|
||||||
|
--channeltextarea-background: #1a1a2e;
|
||||||
|
--modal-background: #141422;
|
||||||
|
--panel-bg: #0c0c14;
|
||||||
|
--embed-background: #161626;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text-default: #e0def0;
|
||||||
|
--text-strong: #f8f8fc;
|
||||||
|
--text-muted: #7a7687;
|
||||||
|
--text-subtle: #a8a5b5;
|
||||||
|
--text-link: #00a8fc;
|
||||||
|
--channels-default: #7a7687;
|
||||||
|
--text-feedback-critical: #ed4245;
|
||||||
|
|
||||||
|
/* Interactive */
|
||||||
|
--interactive-background-hover: rgba(78, 73, 106, 0.2);
|
||||||
|
--interactive-background-active: rgba(78, 73, 106, 0.36);
|
||||||
|
--interactive-background-selected: rgba(78, 73, 106, 0.48);
|
||||||
|
--interactive-icon-default: #a8a5b5;
|
||||||
|
--interactive-icon-hover: #e0def0;
|
||||||
|
--interactive-icon-active: #f8f8fc;
|
||||||
|
--interactive-text-default: #a8a5b5;
|
||||||
|
--interactive-text-hover: #e0def0;
|
||||||
|
--interactive-text-active: #f8f8fc;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border-subtle: rgba(255, 255, 255, 0.2);
|
||||||
|
--border-muted: rgba(255, 255, 255, 0.16);
|
||||||
|
--border-normal: rgba(255, 255, 255, 0.24);
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.44);
|
||||||
|
|
||||||
|
/* Icons */
|
||||||
|
--icon-default: #e0def0;
|
||||||
|
--icon-strong: #f8f8fc;
|
||||||
|
--icon-muted: #7a7687;
|
||||||
|
--icon-subtle: #a8a5b5;
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
|
--control-primary-background-default: #5865f2;
|
||||||
|
--control-primary-background-hover: #4752c4;
|
||||||
|
--control-primary-background-active: #3b43a8;
|
||||||
|
--control-critical-primary-background-default: #ed4245;
|
||||||
|
|
||||||
|
/* Input */
|
||||||
|
--input-background-default: #1a1a2e;
|
||||||
|
--input-border-default: rgba(255, 255, 255, 0.24);
|
||||||
|
--input-text-default: #e0def0;
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
--scrollbar-auto-thumb: #1a1a2e;
|
||||||
|
--scrollbar-thin-thumb: #1a1a2e;
|
||||||
|
|
||||||
|
/* Message */
|
||||||
|
--message-background-hover: rgba(0, 0, 0, 0.12);
|
||||||
|
|
||||||
|
/* Compatibility aliases */
|
||||||
|
--bg-primary: #0c0c14;
|
||||||
|
--bg-secondary: #080810;
|
||||||
|
--bg-tertiary: #000000;
|
||||||
|
--text-normal: #e0def0;
|
||||||
|
--header-primary: #f8f8fc;
|
||||||
|
--header-secondary: #a8a5b5;
|
||||||
|
--interactive-normal: #a8a5b5;
|
||||||
|
--interactive-hover: #e0def0;
|
||||||
|
--interactive-active: #f8f8fc;
|
||||||
|
--brand-experiment: #5865f2;
|
||||||
|
--brand-experiment-hover: #4752c4;
|
||||||
|
--input-background: #1a1a2e;
|
||||||
|
--danger: #ed4245;
|
||||||
|
--background-modifier-hover: rgba(78, 73, 106, 0.2);
|
||||||
|
--background-modifier-active: rgba(78, 73, 106, 0.36);
|
||||||
|
--background-modifier-selected: rgba(78, 73, 106, 0.48);
|
||||||
|
--div-border: #080810;
|
||||||
|
}
|
||||||
7
TODO.md
Normal file
7
TODO.md
Normal 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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -15,6 +15,7 @@ import type * as dms from "../dms.js";
|
|||||||
import type * as files from "../files.js";
|
import type * as files from "../files.js";
|
||||||
import type * as gifs from "../gifs.js";
|
import type * as gifs from "../gifs.js";
|
||||||
import type * as invites from "../invites.js";
|
import type * as invites from "../invites.js";
|
||||||
|
import type * as members from "../members.js";
|
||||||
import type * as messages from "../messages.js";
|
import type * as messages from "../messages.js";
|
||||||
import type * as reactions from "../reactions.js";
|
import type * as reactions from "../reactions.js";
|
||||||
import type * as roles from "../roles.js";
|
import type * as roles from "../roles.js";
|
||||||
@@ -36,6 +37,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
files: typeof files;
|
files: typeof files;
|
||||||
gifs: typeof gifs;
|
gifs: typeof gifs;
|
||||||
invites: typeof invites;
|
invites: typeof invites;
|
||||||
|
members: typeof members;
|
||||||
messages: typeof messages;
|
messages: typeof messages;
|
||||||
reactions: typeof reactions;
|
reactions: typeof reactions;
|
||||||
roles: typeof roles;
|
roles: typeof roles;
|
||||||
|
|||||||
@@ -197,14 +197,62 @@ export const getPublicKeys = query({
|
|||||||
id: v.string(),
|
id: v.string(),
|
||||||
username: v.string(),
|
username: v.string(),
|
||||||
public_identity_key: v.string(),
|
public_identity_key: v.string(),
|
||||||
|
status: v.optional(v.string()),
|
||||||
|
avatarUrl: v.optional(v.union(v.string(), v.null())),
|
||||||
|
aboutMe: v.optional(v.string()),
|
||||||
|
customStatus: v.optional(v.string()),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
const users = await ctx.db.query("userProfiles").collect();
|
const users = await ctx.db.query("userProfiles").collect();
|
||||||
return users.map((u) => ({
|
const results = [];
|
||||||
id: u._id,
|
for (const u of users) {
|
||||||
username: u.username,
|
let avatarUrl: string | null = null;
|
||||||
public_identity_key: u.publicIdentityKey,
|
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;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,13 +31,16 @@ export const list = query({
|
|||||||
_creationTime: v.number(),
|
_creationTime: v.number(),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
type: v.string(),
|
type: v.string(),
|
||||||
|
category: v.optional(v.string()),
|
||||||
|
topic: v.optional(v.string()),
|
||||||
|
position: v.optional(v.number()),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
const channels = await ctx.db.query("channels").collect();
|
const channels = await ctx.db.query("channels").collect();
|
||||||
return channels
|
return channels
|
||||||
.filter((c) => c.type !== "dm")
|
.filter((c) => c.type !== "dm")
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0) || a.name.localeCompare(b.name));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,6 +53,9 @@ export const get = query({
|
|||||||
_creationTime: v.number(),
|
_creationTime: v.number(),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
type: v.string(),
|
type: v.string(),
|
||||||
|
category: v.optional(v.string()),
|
||||||
|
topic: v.optional(v.string()),
|
||||||
|
position: v.optional(v.number()),
|
||||||
}),
|
}),
|
||||||
v.null()
|
v.null()
|
||||||
),
|
),
|
||||||
@@ -63,6 +69,9 @@ export const create = mutation({
|
|||||||
args: {
|
args: {
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
type: v.optional(v.string()),
|
type: v.optional(v.string()),
|
||||||
|
category: v.optional(v.string()),
|
||||||
|
topic: v.optional(v.string()),
|
||||||
|
position: v.optional(v.number()),
|
||||||
},
|
},
|
||||||
returns: v.object({ id: v.id("channels") }),
|
returns: v.object({ id: v.id("channels") }),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
@@ -82,12 +91,30 @@ export const create = mutation({
|
|||||||
const id = await ctx.db.insert("channels", {
|
const id = await ctx.db.insert("channels", {
|
||||||
name: args.name,
|
name: args.name,
|
||||||
type: args.type || "text",
|
type: args.type || "text",
|
||||||
|
category: args.category,
|
||||||
|
topic: args.topic,
|
||||||
|
position: args.position,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { id };
|
return { id };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update channel topic
|
||||||
|
export const updateTopic = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id("channels"),
|
||||||
|
topic: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const channel = await ctx.db.get(args.id);
|
||||||
|
if (!channel) throw new Error("Channel not found");
|
||||||
|
await ctx.db.patch(args.id, { topic: args.topic });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Rename channel
|
// Rename channel
|
||||||
export const rename = mutation({
|
export const rename = mutation({
|
||||||
args: {
|
args: {
|
||||||
@@ -99,6 +126,9 @@ export const rename = mutation({
|
|||||||
_creationTime: v.number(),
|
_creationTime: v.number(),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
type: v.string(),
|
type: v.string(),
|
||||||
|
category: v.optional(v.string()),
|
||||||
|
topic: v.optional(v.string()),
|
||||||
|
position: v.optional(v.number()),
|
||||||
}),
|
}),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
if (!args.name.trim()) {
|
if (!args.name.trim()) {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const listDMs = query({
|
|||||||
channel_name: v.string(),
|
channel_name: v.string(),
|
||||||
other_user_id: v.string(),
|
other_user_id: v.string(),
|
||||||
other_username: v.string(),
|
other_username: v.string(),
|
||||||
|
other_user_status: v.optional(v.string()),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
@@ -78,6 +79,7 @@ export const listDMs = query({
|
|||||||
channel_name: channel.name,
|
channel_name: channel.name,
|
||||||
other_user_id: otherUser._id as string,
|
other_user_id: otherUser._id as string,
|
||||||
other_username: otherUser.username,
|
other_username: otherUser.username,
|
||||||
|
other_user_status: otherUser.status || "online",
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
63
convex/members.ts
Normal file
63
convex/members.ts
Normal 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;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -20,6 +20,11 @@ export const list = query({
|
|||||||
result.page.map(async (msg) => {
|
result.page.map(async (msg) => {
|
||||||
const sender = await ctx.db.get(msg.senderId);
|
const sender = await ctx.db.get(msg.senderId);
|
||||||
|
|
||||||
|
let avatarUrl: string | null = null;
|
||||||
|
if (sender?.avatarStorageId) {
|
||||||
|
avatarUrl = await ctx.storage.getUrl(sender.avatarStorageId);
|
||||||
|
}
|
||||||
|
|
||||||
const reactionDocs = await ctx.db
|
const reactionDocs = await ctx.db
|
||||||
.query("messageReactions")
|
.query("messageReactions")
|
||||||
.withIndex("by_message", (q) => q.eq("messageId", msg._id))
|
.withIndex("by_message", (q) => q.eq("messageId", msg._id))
|
||||||
@@ -34,6 +39,23 @@ export const list = query({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let replyToUsername: string | null = null;
|
||||||
|
let replyToContent: string | null = null;
|
||||||
|
let replyToNonce: string | null = null;
|
||||||
|
let replyToAvatarUrl: string | null = null;
|
||||||
|
if (msg.replyTo) {
|
||||||
|
const repliedMsg = await ctx.db.get(msg.replyTo);
|
||||||
|
if (repliedMsg) {
|
||||||
|
const repliedSender = await ctx.db.get(repliedMsg.senderId);
|
||||||
|
replyToUsername = repliedSender?.username || "Unknown";
|
||||||
|
replyToContent = repliedMsg.ciphertext;
|
||||||
|
replyToNonce = repliedMsg.nonce;
|
||||||
|
if (repliedSender?.avatarStorageId) {
|
||||||
|
replyToAvatarUrl = await ctx.storage.getUrl(repliedSender.avatarStorageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: msg._id,
|
id: msg._id,
|
||||||
channel_id: msg.channelId,
|
channel_id: msg.channelId,
|
||||||
@@ -45,7 +67,15 @@ export const list = query({
|
|||||||
created_at: new Date(msg._creationTime).toISOString(),
|
created_at: new Date(msg._creationTime).toISOString(),
|
||||||
username: sender?.username || "Unknown",
|
username: sender?.username || "Unknown",
|
||||||
public_signing_key: sender?.publicSigningKey || "",
|
public_signing_key: sender?.publicSigningKey || "",
|
||||||
|
avatarUrl,
|
||||||
reactions: Object.keys(reactions).length > 0 ? reactions : null,
|
reactions: Object.keys(reactions).length > 0 ? reactions : null,
|
||||||
|
replyToId: msg.replyTo || null,
|
||||||
|
replyToUsername,
|
||||||
|
replyToContent,
|
||||||
|
replyToNonce,
|
||||||
|
replyToAvatarUrl,
|
||||||
|
editedAt: msg.editedAt || null,
|
||||||
|
pinned: msg.pinned || false,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -62,6 +92,7 @@ export const send = mutation({
|
|||||||
nonce: v.string(),
|
nonce: v.string(),
|
||||||
signature: v.string(),
|
signature: v.string(),
|
||||||
keyVersion: v.number(),
|
keyVersion: v.number(),
|
||||||
|
replyTo: v.optional(v.id("messages")),
|
||||||
},
|
},
|
||||||
returns: v.object({ id: v.id("messages") }),
|
returns: v.object({ id: v.id("messages") }),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
@@ -72,11 +103,74 @@ export const send = mutation({
|
|||||||
nonce: args.nonce,
|
nonce: args.nonce,
|
||||||
signature: args.signature,
|
signature: args.signature,
|
||||||
keyVersion: args.keyVersion,
|
keyVersion: args.keyVersion,
|
||||||
|
replyTo: args.replyTo,
|
||||||
});
|
});
|
||||||
return { id };
|
return { id };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const edit = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id("messages"),
|
||||||
|
ciphertext: v.string(),
|
||||||
|
nonce: v.string(),
|
||||||
|
signature: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.patch(args.id, {
|
||||||
|
ciphertext: args.ciphertext,
|
||||||
|
nonce: args.nonce,
|
||||||
|
signature: args.signature,
|
||||||
|
editedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pin = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id("messages"),
|
||||||
|
pinned: v.boolean(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.patch(args.id, { pinned: args.pinned });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listPinned = query({
|
||||||
|
args: {
|
||||||
|
channelId: v.id("channels"),
|
||||||
|
},
|
||||||
|
returns: v.any(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const allMessages = await ctx.db
|
||||||
|
.query("messages")
|
||||||
|
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const pinned = allMessages.filter((m) => m.pinned === true);
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
pinned.map(async (msg) => {
|
||||||
|
const sender = await ctx.db.get(msg.senderId);
|
||||||
|
return {
|
||||||
|
id: msg._id,
|
||||||
|
ciphertext: msg.ciphertext,
|
||||||
|
nonce: msg.nonce,
|
||||||
|
signature: msg.signature,
|
||||||
|
key_version: msg.keyVersion,
|
||||||
|
created_at: new Date(msg._creationTime).toISOString(),
|
||||||
|
username: sender?.username || "Unknown",
|
||||||
|
public_signing_key: sender?.publicSigningKey || "",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const remove = mutation({
|
export const remove = mutation({
|
||||||
args: { id: v.id("messages") },
|
args: { id: v.id("messages") },
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
|
|||||||
@@ -11,11 +11,18 @@ export default defineSchema({
|
|||||||
publicSigningKey: v.string(),
|
publicSigningKey: v.string(),
|
||||||
encryptedPrivateKeys: v.string(),
|
encryptedPrivateKeys: v.string(),
|
||||||
isAdmin: v.boolean(),
|
isAdmin: v.boolean(),
|
||||||
|
status: v.optional(v.string()),
|
||||||
|
avatarStorageId: v.optional(v.id("_storage")),
|
||||||
|
aboutMe: v.optional(v.string()),
|
||||||
|
customStatus: v.optional(v.string()),
|
||||||
}).index("by_username", ["username"]),
|
}).index("by_username", ["username"]),
|
||||||
|
|
||||||
channels: defineTable({
|
channels: defineTable({
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
type: v.string(), // 'text' | 'voice' | 'dm'
|
type: v.string(), // 'text' | 'voice' | 'dm'
|
||||||
|
category: v.optional(v.string()),
|
||||||
|
topic: v.optional(v.string()),
|
||||||
|
position: v.optional(v.number()),
|
||||||
}).index("by_name", ["name"]),
|
}).index("by_name", ["name"]),
|
||||||
|
|
||||||
messages: defineTable({
|
messages: defineTable({
|
||||||
@@ -25,6 +32,9 @@ export default defineSchema({
|
|||||||
nonce: v.string(),
|
nonce: v.string(),
|
||||||
signature: v.string(),
|
signature: v.string(),
|
||||||
keyVersion: v.number(),
|
keyVersion: v.number(),
|
||||||
|
replyTo: v.optional(v.id("messages")),
|
||||||
|
editedAt: v.optional(v.number()),
|
||||||
|
pinned: v.optional(v.boolean()),
|
||||||
}).index("by_channel", ["channelId"]),
|
}).index("by_channel", ["channelId"]),
|
||||||
|
|
||||||
messageReactions: defineTable({
|
messageReactions: defineTable({
|
||||||
|
|||||||
15753
discord-html-copy/css/015ca14af3bac62c.css
Normal file
15753
discord-html-copy/css/015ca14af3bac62c.css
Normal file
File diff suppressed because it is too large
Load Diff
9110
discord-html-copy/css/076e97f00ed91b8f.css
Normal file
9110
discord-html-copy/css/076e97f00ed91b8f.css
Normal file
File diff suppressed because it is too large
Load Diff
9891
discord-html-copy/css/0fe7a252fcb6f0c6.css
Normal file
9891
discord-html-copy/css/0fe7a252fcb6f0c6.css
Normal file
File diff suppressed because it is too large
Load Diff
5316
discord-html-copy/css/1803f89f7fb95846.css
Normal file
5316
discord-html-copy/css/1803f89f7fb95846.css
Normal file
File diff suppressed because it is too large
Load Diff
2000
discord-html-copy/css/1f7b87510348dd0c.css
Normal file
2000
discord-html-copy/css/1f7b87510348dd0c.css
Normal file
File diff suppressed because it is too large
Load Diff
3083
discord-html-copy/css/3ec8cc660f6f6173.css
Normal file
3083
discord-html-copy/css/3ec8cc660f6f6173.css
Normal file
File diff suppressed because it is too large
Load Diff
2016
discord-html-copy/css/4845f272d76c596f.css
Normal file
2016
discord-html-copy/css/4845f272d76c596f.css
Normal file
File diff suppressed because it is too large
Load Diff
2482
discord-html-copy/css/4851cc625502983d.css
Normal file
2482
discord-html-copy/css/4851cc625502983d.css
Normal file
File diff suppressed because it is too large
Load Diff
1447
discord-html-copy/css/587404af83693d7c.css
Normal file
1447
discord-html-copy/css/587404af83693d7c.css
Normal file
File diff suppressed because it is too large
Load Diff
160
discord-html-copy/css/59c6d21704b78874.css
Normal file
160
discord-html-copy/css/59c6d21704b78874.css
Normal 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*/
|
||||||
13
discord-html-copy/css/6f7713d5b10d7cb3.css
Normal file
13
discord-html-copy/css/6f7713d5b10d7cb3.css
Normal 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*/
|
||||||
1334
discord-html-copy/css/74c035a23e6d0bbb.css
Normal file
1334
discord-html-copy/css/74c035a23e6d0bbb.css
Normal file
File diff suppressed because it is too large
Load Diff
14297
discord-html-copy/css/901301eeddfe8f4b.css
Normal file
14297
discord-html-copy/css/901301eeddfe8f4b.css
Normal file
File diff suppressed because it is too large
Load Diff
212
discord-html-copy/css/9296efc0128dcaf8.css
Normal file
212
discord-html-copy/css/9296efc0128dcaf8.css
Normal 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*/
|
||||||
1174
discord-html-copy/css/9b2747232e9f34a1.css
Normal file
1174
discord-html-copy/css/9b2747232e9f34a1.css
Normal file
File diff suppressed because it is too large
Load Diff
65
discord-html-copy/css/a06f142ee55db4f5.css
Normal file
65
discord-html-copy/css/a06f142ee55db4f5.css
Normal 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*/
|
||||||
6122
discord-html-copy/css/f37149369742ded7.css
Normal file
6122
discord-html-copy/css/f37149369742ded7.css
Normal file
File diff suppressed because it is too large
Load Diff
70519
discord-html-copy/css/web.cf1abc4e5994931f.css
Normal file
70519
discord-html-copy/css/web.cf1abc4e5994931f.css
Normal file
File diff suppressed because it is too large
Load Diff
1
discord-html-copy/mention menu/mention snippit.txt
Normal file
1
discord-html-copy/mention menu/mention snippit.txt
Normal file
File diff suppressed because one or more lines are too long
1
discord-html-copy/reply snippit.txt
Normal file
1
discord-html-copy/reply snippit.txt
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user