feat: Add initial Discord clone application with Convex backend services and Electron React frontend components.

This commit is contained in:
Bryan1029384756
2026-02-10 05:27:10 -06:00
parent 47f173c79b
commit 34e9790db9
29 changed files with 3254 additions and 1398 deletions

View File

@@ -10,7 +10,8 @@
"Bash(npx convex dev:*)", "Bash(npx convex dev:*)",
"Bash(npx convex:*)", "Bash(npx convex:*)",
"Bash(npx @convex-dev/auth:*)", "Bash(npx @convex-dev/auth:*)",
"Bash(dir:*)" "Bash(dir:*)",
"Bash(npx vite build:*)"
] ]
} }
} }

View File

@@ -5,7 +5,7 @@
<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-B1qeTixj.js"></script> <script type="module" crossorigin src="./assets/index-DXKRzYO-.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-D1fin5Al.css"> <link rel="stylesheet" crossorigin href="./assets/index-D1fin5Al.css">
</head> </head>
<body> <body>

View File

@@ -0,0 +1,3 @@
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
<path fill="currentColor" fill-rule="evenodd" d="M11 1.576 6.583 6 11 10.424l-.576.576L6 6.583 1.576 11 1 10.424 5.417 6 1 1.576 1.576 1 6 5.417 10.424 1z"/>
</svg>

After

Width:  |  Height:  |  Size: 270 B

View File

@@ -0,0 +1,4 @@
<svg class="icon__9293f" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" fill="transparent"/>
<path fill="currentColor" fill-rule="evenodd" d="M12 23a11 11 0 1 0 0-22 11 11 0 0 0 0 22m-.28-16c-.98 0-1.81.47-2.27 1.14A1 1 0 1 1 7.8 7.01 4.73 4.73 0 0 1 11.72 5c2.5 0 4.65 1.88 4.65 4.38 0 2.1-1.54 3.77-3.52 4.24l.14 1a1 1 0 0 1-1.98.27l-.28-2a1 1 0 0 1 .99-1.14c1.54 0 2.65-1.14 2.65-2.38 0-1.23-1.1-2.37-2.65-2.37M13 17.88a1.13 1.13 0 1 1-2.25 0 1.13 1.13 0 0 1 2.25 0" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 597 B

View File

@@ -0,0 +1,3 @@
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" fill-rule="evenodd" d="M5 2a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V5a3 3 0 0 0-3-3zM4 5.5C4 4.67 4.67 4 5.5 4h13c.83 0 1.5.67 1.5 1.5v6c0 .83-.67 1.5-1.5 1.5h-2.65c-.5 0-.85.5-.85 1a3 3 0 1 1-6 0c0-.5-.35-1-.85-1H5.5A1.5 1.5 0 0 1 4 11.5z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 421 B

View File

@@ -0,0 +1,3 @@
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
<path fill="none" stroke="currentColor" d="M1.5 1.5h9v9h-9z"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

View File

@@ -0,0 +1,3 @@
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
<path fill="currentColor" d="M1 6h10v1H1z"/>
</svg>

After

Width:  |  Height:  |  Size: 157 B

View File

@@ -0,0 +1,3 @@
<svg class="icon__9293f" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 2a1 1 0 0 1 1 1v10.59l3.3-3.3a1 1 0 1 1 1.4 1.42l-5 5a1 1 0 0 1-1.4 0l-5-5a1 1 0 1 1 1.4-1.42l3.3 3.3V3a1 1 0 0 1 1-1M3 20a1 1 0 1 0 0 2h18a1 1 0 1 0 0-2z" class="updateIconForeground__49676"/>
</svg>

After

Width:  |  Height:  |  Size: 371 B

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { useQuery, useMutation, useConvex } from 'convex/react'; import { useQuery, usePaginatedQuery, useMutation, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api'; import { api } from '../../../../convex/_generated/api';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
@@ -27,14 +27,71 @@ const heartIcon = getEmojiUrl('symbols', 'heart');
const thumbsupIcon = getEmojiUrl('people', 'thumbsup'); const thumbsupIcon = getEmojiUrl('people', 'thumbsup');
import GifPicker from './GifPicker'; import GifPicker from './GifPicker';
// Cache for link metadata to prevent pop-in
const metadataCache = new Map(); const metadataCache = new Map();
const attachmentCache = new Map();
const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)';
const ICON_COLOR_DANGER = 'color-mix(in oklab, hsl(1.353 calc(1*82.609%) 68.431% /1) 100%, #000 0%)';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
const fromHexString = (hexString) =>
new Uint8Array(hexString.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)));
const toHexString = (bytes) =>
bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
const 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 getReactionIcon = (name) => {
switch (name) {
case 'thumbsup': return thumbsupIcon;
case 'heart': return heartIcon;
case 'fire': return fireIcon;
default: return heartIcon;
}
};
const extractUrls = (text) => {
const urlRegex = /(https?:\/\/[^\s]+)/g;
return text.match(urlRegex) || [];
};
const getYouTubeId = (link) => {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
const match = link.match(regExp);
return (match && match[2].length === 11) ? match[2] : null;
};
const formatMentions = (text) => {
if (!text) return '';
return text.replace(/@(\w+)/g, '[@$1](mention://$1)');
};
const formatEmojis = (text) => {
if (!text) return '';
return text.replace(/:([a-zA-Z0-9_]+):/g, (match, name) => {
const emoji = AllEmojis.find(e => e.name === name);
return emoji ? `![${match}](${emoji.src})` : match;
});
};
const isNewDay = (current, previous) => {
if (!previous) return true;
return current.getDate() !== previous.getDate()
|| current.getMonth() !== previous.getMonth()
|| current.getFullYear() !== previous.getFullYear();
};
// Extracted LinkPreview to prevent re-renders on ChatArea updates
const LinkPreview = ({ url }) => { const LinkPreview = ({ url }) => {
const [metadata, setMetadata] = useState(metadataCache.get(url) || null); const [metadata, setMetadata] = useState(metadataCache.get(url) || null);
const [loading, setLoading] = useState(!metadataCache.has(url)); const [loading, setLoading] = useState(!metadataCache.has(url));
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [showControls, setShowControls] = useState(false);
const videoRef = useRef(null);
useEffect(() => { useEffect(() => {
if (metadataCache.has(url)) { if (metadataCache.has(url)) {
@@ -61,15 +118,6 @@ const LinkPreview = ({ url }) => {
return () => { isMounted = false; }; return () => { isMounted = false; };
}, [url]); }, [url]);
const [showControls, setShowControls] = useState(false);
const videoRef = useRef(null);
const getYouTubeId = (link) => {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
const match = link.match(regExp);
return (match && match[2].length === 11) ? match[2] : null;
};
const videoId = getYouTubeId(url); const videoId = getYouTubeId(url);
const isYouTube = !!videoId; const isYouTube = !!videoId;
@@ -78,9 +126,7 @@ const LinkPreview = ({ url }) => {
if (metadata.video && !isYouTube) { if (metadata.video && !isYouTube) {
const handlePlayClick = () => { const handlePlayClick = () => {
setShowControls(true); setShowControls(true);
if (videoRef.current) { if (videoRef.current) videoRef.current.play();
videoRef.current.play();
}
}; };
return ( return (
@@ -140,14 +186,6 @@ const LinkPreview = ({ url }) => {
); );
}; };
const fromHexString = (hexString) =>
new Uint8Array(hexString.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)));
const toHexString = (bytes) =>
bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
const attachmentCache = new Map();
const Attachment = ({ metadata, onLoad, onImageClick }) => { const Attachment = ({ metadata, onLoad, onImageClick }) => {
const [url, setUrl] = useState(attachmentCache.get(metadata.url) || null); const [url, setUrl] = useState(attachmentCache.get(metadata.url) || null);
const [loading, setLoading] = useState(!attachmentCache.has(metadata.url)); const [loading, setLoading] = useState(!attachmentCache.has(metadata.url));
@@ -243,24 +281,30 @@ const PendingFilePreview = ({ file, onRemove }) => {
<ColoredIcon src={icon} color="#fff" size="16px" /> <ColoredIcon src={icon} color="#fff" size="16px" />
</div> </div>
); );
return (
<div style={{ display: 'inline-flex', flexDirection: 'column', marginRight: '10px' }}> let previewContent;
<div style={{ position: 'relative', width: '200px', height: '200px', borderRadius: '8px', backgroundColor: '#2f3136', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}> if (preview && isVideo) {
{preview ? ( previewContent = (
isVideo ? (
<> <>
<video src={preview} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> <video src={preview} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', fontSize: '24px', color: 'white', textShadow: '0 1px 4px rgba(0,0,0,0.7)', pointerEvents: 'none' }}></div> <div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', fontSize: '24px', color: 'white', textShadow: '0 1px 4px rgba(0,0,0,0.7)', pointerEvents: 'none' }}></div>
</> </>
) : ( );
<img src={preview} alt="Preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> } else if (preview) {
) previewContent = <img src={preview} alt="Preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />;
) : ( } else {
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: '#b9bbbe', marginTop: '4px', maxWidth: '90px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</div>
</div> </div>
)} );
}
return (
<div style={{ display: 'inline-flex', flexDirection: 'column', marginRight: '10px' }}>
<div style={{ position: 'relative', width: '200px', height: '200px', borderRadius: '8px', backgroundColor: '#2f3136', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
{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={() => {}} />
<ActionButton icon={EditIcon} onClick={() => {}} /> <ActionButton icon={EditIcon} onClick={() => {}} />
@@ -307,10 +351,10 @@ const MessageToolbar = ({ onAddReaction, onEdit, onReply, onMore, isOwner }) =>
<IconButton onClick={() => onAddReaction('heart')} title="Add Reaction" emoji={<ColoredIcon src={heartIcon} size="20px" />} /> <IconButton onClick={() => onAddReaction('heart')} title="Add Reaction" emoji={<ColoredIcon src={heartIcon} size="20px" />} />
<IconButton onClick={() => onAddReaction('fire')} title="Add Reaction" emoji={<ColoredIcon src={fireIcon} size="20px" />} /> <IconButton onClick={() => onAddReaction('fire')} title="Add Reaction" emoji={<ColoredIcon src={fireIcon} size="20px" />} />
<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="color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)" size="20px" />} /> <IconButton onClick={() => onAddReaction(null)} title="Add Reaction" emoji={<ColoredIcon src={EmojieIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
{isOwner && <IconButton onClick={onEdit} title="Edit" emoji={<ColoredIcon src={EditIcon} color="color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)" size="20px" />} />} {isOwner && <IconButton onClick={onEdit} title="Edit" emoji={<ColoredIcon src={EditIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />}
<IconButton onClick={onReply} title="Reply" emoji={<ColoredIcon src={ReplyIcon} color="color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)" size="20px" />} /> <IconButton onClick={onReply} title="Reply" emoji={<ColoredIcon src={ReplyIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
<IconButton onClick={onMore} title="More" emoji={<ColoredIcon src={MoreIcon} color="color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)" size="20px" />} /> <IconButton onClick={onMore} title="More" emoji={<ColoredIcon src={MoreIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
</div> </div>
); );
@@ -325,7 +369,7 @@ const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner }) => {
const [pos, setPos] = useState({ top: y, left: x }); const [pos, setPos] = useState({ top: y, left: x });
useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); return () => window.removeEventListener('click', h); }, [onClose]); useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); return () => window.removeEventListener('click', h); }, [onClose]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (menuRef.current) { if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect(); const rect = menuRef.current.getBoundingClientRect();
let newTop = y, newLeft = x; let newTop = y, newLeft = x;
if (x + rect.width > window.innerWidth) newLeft = x - rect.width; if (x + rect.width > window.innerWidth) newLeft = x - rect.width;
@@ -333,11 +377,10 @@ const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner }) => {
if (newLeft < 0) newLeft = 10; if (newLeft < 0) newLeft = 10;
if (newTop < 0) newTop = 10; if (newTop < 0) newTop = 10;
setPos({ top: newTop, left: newLeft }); setPos({ top: newTop, left: newLeft });
}
}, [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 ? 'color-mix(in oklab, hsl(1.353 calc(1*82.609%) 68.431% /1) 100%, #000 0%)' : '#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(); }} 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'}>
<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>
@@ -345,13 +388,13 @@ const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner }) => {
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} 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()}>
<MenuItem label="Add Reaction" iconSrc={EmojieIcon} iconColor='color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)' onClick={() => onInteract('reaction')} /> <MenuItem label="Add Reaction" iconSrc={EmojieIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('reaction')} />
{isOwner && <MenuItem label="Edit Message" iconSrc={EditIcon} iconColor='color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)' onClick={() => onInteract('edit')} />} {isOwner && <MenuItem label="Edit Message" iconSrc={EditIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('edit')} />}
<MenuItem label="Reply" iconSrc={ReplyIcon} iconColor='color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)' 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 style={{ height: '1px', backgroundColor: '#36393f', margin: '4px 0' }} />
<MenuItem label="Pin Message" iconSrc={PinIcon} iconColor='color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)' 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 style={{ height: '1px', backgroundColor: '#36393f', margin: '4px 0' }} />
{isOwner && <MenuItem label="Delete Message" iconSrc={DeleteIcon} iconColor='color-mix(in oklab, hsl(1.353 calc(1*82.609%) 68.431% /1) 100%, #000 0%)' danger onClick={() => onInteract('delete')} />} {isOwner && <MenuItem label="Delete Message" iconSrc={DeleteIcon} iconColor={ICON_COLOR_DANGER} danger onClick={() => onInteract('delete')} />}
</div> </div>
); );
}; };
@@ -362,29 +405,75 @@ const ColoredIcon = ({ src, color, size = '24px', style = {} }) => (
</div> </div>
); );
const markdownComponents = {
a: ({ node, ...props }) => {
if (props.href && props.href.startsWith('mention://')) return <span style={{ background: 'color-mix(in oklab,hsl(234.935 calc(1*85.556%) 64.706% /0.23921568627450981) 100%,hsl(0 0% 0% /0.23921568627450981) 0%)', borderRadius: '3px', color: 'color-mix(in oklab, hsl(228.14 calc(1*100%) 83.137% /1) 100%, #000 0%)', fontWeight: 500, padding: '0 2px', cursor: 'default' }} title={props.children}>{props.children}</span>;
return <a {...props} onClick={(e) => { e.preventDefault(); window.cryptoAPI.openExternal(props.href); }} style={{ color: '#00b0f4', cursor: 'pointer', textDecoration: 'none' }} onMouseOver={(e) => e.target.style.textDecoration = 'underline'} onMouseOut={(e) => e.target.style.textDecoration = 'none'} />;
},
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? <SyntaxHighlighter style={oneDark} language={match[1]} PreTag="div" {...props}>{String(children).replace(/\n$/, '')}</SyntaxHighlighter> : <code className={className} {...props}>{children}</code>;
},
p: ({ node, ...props }) => <p style={{ margin: '0 0 6px 0' }} {...props} />,
h1: ({ node, ...props }) => <h1 style={{ fontSize: '1.5rem', fontWeight: 700, margin: '12px 0 6px 0', lineHeight: 1.1 }} {...props} />,
h2: ({ node, ...props }) => <h2 style={{ fontSize: '1.25rem', fontWeight: 700, margin: '10px 0 6px 0', lineHeight: 1.1 }} {...props} />,
h3: ({ node, ...props }) => <h3 style={{ fontSize: '1.1rem', fontWeight: 700, margin: '8px 0 6px 0', lineHeight: 1.1 }} {...props} />,
ul: ({ node, ...props }) => <ul style={{ margin: '0 0 6px 0', paddingLeft: '20px' }} {...props} />,
ol: ({ node, ...props }) => <ol style={{ margin: '0 0 6px 0', paddingLeft: '20px' }} {...props} />,
li: ({ node, ...props }) => <li style={{ margin: '0' }} {...props} />,
hr: ({ node, ...props }) => <hr style={{ border: 'none', borderTop: '1px solid #4f545c', margin: '12px 0' }} {...props} />,
img: ({ node, alt, src, ...props }) => {
if (alt && alt.startsWith(':') && alt.endsWith(':')) {
return <img src={src} alt={alt} style={{ width: '22px', height: '22px', verticalAlign: 'bottom', margin: '0 1px', display: 'inline' }} />;
}
return <img alt={alt} src={src} {...props} />;
},
};
const parseAttachment = (content) => {
if (!content || !content.startsWith('{')) return null;
try {
const parsed = JSON.parse(content);
return parsed.type === 'attachment' ? parsed : null;
} catch (e) {
return null;
}
};
const ChatArea = ({ channelId, channelName, username, channelKey, userId: currentUserId }) => { const ChatArea = ({ channelId, channelName, username, channelKey, userId: currentUserId }) => {
const [decryptedMessages, setDecryptedMessages] = useState([]); const [decryptedMessages, setDecryptedMessages] = useState([]);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null);
const inputDivRef = useRef(null);
const [zoomedImage, setZoomedImage] = useState(null); const [zoomedImage, setZoomedImage] = useState(null);
const [showGifPicker, setShowGifPicker] = useState(false); const [pickerTab, setPickerTab] = useState(null);
const [pickerActiveTab, setPickerActiveTab] = useState('GIFs');
const savedRangeRef = useRef(null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [pendingFiles, setPendingFiles] = useState([]); const [pendingFiles, setPendingFiles] = useState([]);
const [hasImages, setHasImages] = useState(false); const [hasImages, setHasImages] = useState(false);
const [isMultiline, setIsMultiline] = useState(false); const [isMultiline, setIsMultiline] = useState(false);
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 messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null);
const inputDivRef = useRef(null);
const savedRangeRef = useRef(null);
const fileInputRef = useRef(null);
const typingTimeoutRef = useRef(null);
const lastTypingEmitRef = useRef(0);
const decryptionCacheRef = useRef(new Map());
const isInitialLoadRef = useRef(true);
const prevScrollHeightRef = useRef(0);
const isLoadingMoreRef = useRef(false);
const userSentMessageRef = useRef(false);
const topSentinelRef = useRef(null);
const prevResultsLengthRef = useRef(0);
const convex = useConvex(); const convex = useConvex();
// Convex reactive queries - replaces socket.io listeners! const { results: rawMessages, status, loadMore } = usePaginatedQuery(
const rawMessages = useQuery(
api.messages.list, api.messages.list,
channelId ? { channelId, userId: currentUserId || undefined } : "skip" channelId ? { channelId, userId: currentUserId || undefined } : "skip",
{ initialNumItems: 50 }
); );
const typingData = useQuery( const typingData = useQuery(
@@ -392,32 +481,20 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
channelId ? { channelId } : "skip" channelId ? { channelId } : "skip"
) || []; ) || [];
// Convex mutations
const sendMessageMutation = useMutation(api.messages.send); const sendMessageMutation = useMutation(api.messages.send);
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);
const stopTypingMutation = useMutation(api.typing.stopTyping); const stopTypingMutation = useMutation(api.typing.stopTyping);
const typingTimeoutRef = useRef(null); const showGifPicker = pickerTab !== null;
const lastTypingEmitRef = useRef(0);
// Close GIF picker when clicking outside
useEffect(() => { useEffect(() => {
const handleClickOutside = () => { if (showGifPicker) setShowGifPicker(false); }; const handleClickOutside = () => { if (showGifPicker) setPickerTab(null); };
window.addEventListener('click', handleClickOutside); window.addEventListener('click', handleClickOutside);
return () => window.removeEventListener('click', handleClickOutside); return () => window.removeEventListener('click', handleClickOutside);
}, [showGifPicker]); }, [showGifPicker]);
const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)';
const getUserColor = (username) => {
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 decryptMessage = async (msg) => { const decryptMessage = async (msg) => {
try { try {
const TAG_LENGTH = 32; const TAG_LENGTH = 32;
@@ -432,36 +509,60 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
} }
}; };
const extractUrls = (text) => { const urlRegex = /(https?:\/\/[^\s]+)/g; return text.match(urlRegex) || []; };
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); }
catch (e) { console.error('Verification error for msg:', msg.id, e); return false; } catch (e) { console.error('Verification error for msg:', msg.id, e); return false; }
}; };
// Decrypt messages when raw messages change
useEffect(() => { useEffect(() => {
if (!rawMessages) return; if (!rawMessages || rawMessages.length === 0) {
setDecryptedMessages([]);
return;
}
const cache = decryptionCacheRef.current;
let cancelled = false;
const processMessages = async () => { const processMessages = async () => {
const processed = await Promise.all(rawMessages.map(async (msg) => { // Decrypt only messages not already in cache
const needsDecryption = rawMessages.filter(msg => !cache.has(msg.id));
if (needsDecryption.length > 0) {
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);
return { ...msg, content, isVerified }; if (!cancelled) {
cache.set(msg.id, { content, isVerified });
}
})); }));
}
if (cancelled) return;
// Build full chronological array (rawMessages is newest-first, reverse for display)
const processed = [...rawMessages].reverse().map(msg => {
const cached = cache.get(msg.id);
return {
...msg,
content: cached?.content ?? '[Decrypting...]',
isVerified: cached?.isVerified ?? false,
};
});
setDecryptedMessages(processed); setDecryptedMessages(processed);
}; };
processMessages(); processMessages();
return () => { cancelled = true; };
}, [rawMessages, channelKey]); }, [rawMessages, channelKey]);
// Clear messages on channel change // Clear decryption cache and reset scroll state on channel/key change
useEffect(() => { useEffect(() => {
decryptionCacheRef.current.clear();
setDecryptedMessages([]); setDecryptedMessages([]);
}, [channelId]); isInitialLoadRef.current = true;
prevResultsLengthRef.current = 0;
}, [channelId, channelKey]);
// Filter typing users (exclude self)
const typingUsers = typingData.filter(t => t.username !== username); const typingUsers = typingData.filter(t => t.username !== username);
const scrollToBottom = useCallback((force = false) => { const scrollToBottom = useCallback((force = false) => {
@@ -475,14 +576,75 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
} }
}, []); }, []);
useEffect(() => { // Scroll management via useLayoutEffect (fires before paint)
if (decryptedMessages.length > 0) { useLayoutEffect(() => {
const lastMsg = decryptedMessages[decryptedMessages.length - 1]; const container = messagesContainerRef.current;
scrollToBottom(lastMsg.username === username); if (!container || decryptedMessages.length === 0) return;
}
}, [decryptedMessages, username, scrollToBottom]);
const fileInputRef = useRef(null); // Initial load — instant scroll to bottom (no animation)
if (isInitialLoadRef.current) {
container.scrollTop = container.scrollHeight;
isInitialLoadRef.current = false;
prevResultsLengthRef.current = rawMessages?.length || 0;
return;
}
// Load more (older messages prepended) — preserve scroll position
if (isLoadingMoreRef.current) {
const newScrollHeight = container.scrollHeight;
const heightDifference = newScrollHeight - prevScrollHeightRef.current;
container.scrollTop += heightDifference;
isLoadingMoreRef.current = false;
prevResultsLengthRef.current = rawMessages?.length || 0;
return;
}
// User sent a message — force scroll to bottom
if (userSentMessageRef.current) {
container.scrollTop = container.scrollHeight;
userSentMessageRef.current = false;
prevResultsLengthRef.current = rawMessages?.length || 0;
return;
}
// Real-time new message — auto-scroll if near bottom
const currentLen = rawMessages?.length || 0;
const prevLen = prevResultsLengthRef.current;
if (currentLen > prevLen && (currentLen - prevLen) <= 5) {
const { scrollTop, scrollHeight, clientHeight } = container;
if (scrollHeight - scrollTop - clientHeight < 300) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}
prevResultsLengthRef.current = currentLen;
}, [decryptedMessages, rawMessages?.length]);
// IntersectionObserver to trigger loadMore when scrolling near the top
useEffect(() => {
const sentinel = topSentinelRef.current;
const container = messagesContainerRef.current;
if (!sentinel || !container) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && status === 'CanLoadMore') {
prevScrollHeightRef.current = container.scrollHeight;
isLoadingMoreRef.current = true;
loadMore(50);
}
},
{ root: container, rootMargin: '200px 0px 0px 0px', threshold: 0 }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [status, loadMore]);
const saveSelection = () => {
const sel = window.getSelection();
if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange();
};
const insertEmoji = (emoji) => { const insertEmoji = (emoji) => {
if (!inputDivRef.current) return; if (!inputDivRef.current) return;
@@ -508,13 +670,16 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
if (!selection.rangeCount) return; if (!selection.rangeCount) return;
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
const node = range.startContainer; const node = range.startContainer;
if (node.nodeType === Node.TEXT_NODE) { if (node.nodeType !== Node.TEXT_NODE) return;
const content = node.textContent.substring(0, range.startOffset); const content = node.textContent.substring(0, range.startOffset);
const match = content.match(/:([a-zA-Z0-9_]+):$/); const match = content.match(/:([a-zA-Z0-9_]+):$/);
if (match) { if (!match) return;
const name = match[1]; const name = match[1];
const emoji = AllEmojis.find(e => e.name === name); const emoji = AllEmojis.find(e => e.name === name);
if (emoji) { if (!emoji) return;
const img = document.createElement('img'); const img = document.createElement('img');
img.src = emoji.src; img.alt = `:${name}:`; img.className = "inline-emoji"; img.src = emoji.src; img.alt = `:${name}:`; img.className = "inline-emoji";
img.style.width = "22px"; img.style.height = "22px"; img.style.verticalAlign = "bottom"; img.style.margin = "0 1px"; img.contentEditable = "false"; img.style.width = "22px"; img.style.height = "22px"; img.style.verticalAlign = "bottom"; img.style.margin = "0 1px"; img.contentEditable = "false";
@@ -525,13 +690,8 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
if (node.nextSibling) { node.parentNode.insertBefore(img, node.nextSibling); node.parentNode.insertBefore(afterNode, img.nextSibling); } if (node.nextSibling) { node.parentNode.insertBefore(img, node.nextSibling); node.parentNode.insertBefore(afterNode, img.nextSibling); }
else { node.parentNode.appendChild(img); node.parentNode.appendChild(afterNode); } else { node.parentNode.appendChild(img); node.parentNode.appendChild(afterNode); }
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 [uploading, setUploading] = useState(false);
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); };
@@ -547,12 +707,10 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
const encryptedBytes = fromHexString(encryptedHex); const encryptedBytes = fromHexString(encryptedHex);
const blob = new Blob([encryptedBytes], { type: 'application/octet-stream' }); const blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
// Upload to Convex storage
const uploadUrl = await convex.mutation(api.files.generateUploadUrl, {}); const uploadUrl = await convex.mutation(api.files.generateUploadUrl, {});
const uploadRes = await fetch(uploadUrl, { method: 'POST', headers: { 'Content-Type': blob.type }, body: blob }); const uploadRes = await fetch(uploadUrl, { method: 'POST', headers: { 'Content-Type': blob.type }, body: blob });
const { storageId } = await uploadRes.json(); const { storageId } = await uploadRes.json();
// Get the file URL
const fileUrl = await convex.query(api.files.getFileUrl, { storageId }); const fileUrl = await convex.query(api.files.getFileUrl, { storageId });
const metadata = { const metadata = {
@@ -590,6 +748,14 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
} }
}; };
const clearTypingState = () => {
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
if (currentUserId && channelId) {
stopTypingMutation({ channelId, userId: currentUserId }).catch(() => {});
}
lastTypingEmitRef.current = 0;
};
const handleSend = async (e) => { const handleSend = async (e) => {
e.preventDefault(); e.preventDefault();
let messageContent = ''; let messageContent = '';
@@ -605,6 +771,7 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
if (!messageContent && pendingFiles.length === 0) return; if (!messageContent && pendingFiles.length === 0) return;
setUploading(true); setUploading(true);
userSentMessageRef.current = true;
try { try {
for (const file of pendingFiles) await uploadAndSendFile(file); for (const file of pendingFiles) await uploadAndSendFile(file);
setPendingFiles([]); setPendingFiles([]);
@@ -612,11 +779,7 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
await sendMessage(messageContent); await sendMessage(messageContent);
if (inputDivRef.current) inputDivRef.current.innerHTML = ''; if (inputDivRef.current) inputDivRef.current.innerHTML = '';
setInput(''); setHasImages(false); setInput(''); setHasImages(false);
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); clearTypingState();
if (currentUserId && channelId) {
stopTypingMutation({ channelId, userId: currentUserId }).catch(() => {});
}
lastTypingEmitRef.current = 0;
} }
} catch (err) { } catch (err) {
console.error("Error sending message/files:", err); console.error("Error sending message/files:", err);
@@ -646,17 +809,6 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
} }
}; };
const formatMentions = (text) => { if (!text) return ''; return text.replace(/@(\w+)/g, '[@$1](mention://$1)'); };
const formatEmojis = (text) => {
if (!text) return '';
return text.replace(/:([a-zA-Z0-9_]+):/g, (match, name) => {
const emoji = AllEmojis.find(e => e.name === name);
if (emoji) return `![${match}](${emoji.src})`;
return match;
});
};
const handleReactionClick = async (messageId, emoji, hasMyReaction) => { const handleReactionClick = async (messageId, emoji, hasMyReaction) => {
if (!currentUserId) return; if (!currentUserId) return;
if (hasMyReaction) { if (hasMyReaction) {
@@ -666,95 +818,124 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
} }
}; };
const togglePicker = (tab) => {
if (pickerTab === tab) {
setPickerTab(null);
} else {
setPickerTab(tab);
}
};
const renderMessageContent = (msg) => {
const attachmentMetadata = parseAttachment(msg.content);
if (attachmentMetadata) {
return <Attachment metadata={attachmentMetadata} onLoad={scrollToBottom} onImageClick={setZoomedImage} />;
}
const urls = extractUrls(msg.content);
const isOnlyUrl = urls.length === 1 && msg.content.trim() === urls[0];
const isGif = isOnlyUrl && (urls[0].includes('tenor.com') || urls[0].includes('giphy.com') || urls[0].endsWith('.gif'));
return (
<>
{!isGif && (
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={(url) => url} components={markdownComponents}>
{formatEmojis(formatMentions(msg.content))}
</ReactMarkdown>
)}
{urls.map((url, i) => <LinkPreview key={i} url={url} />)}
</>
);
};
const renderReactions = (msg) => {
if (!msg.reactions || Object.keys(msg.reactions).length === 0) return null;
return (
<div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}>
{Object.entries(msg.reactions).map(([emojiName, data]) => (
<div key={emojiName} onClick={() => handleReactionClick(msg.id, emojiName, data.me)} style={{ display: 'flex', alignItems: 'center', backgroundColor: data.me ? 'rgba(88, 101, 242, 0.15)' : '#2f3136', border: data.me ? '1px solid #5865F2' : '1px solid transparent', borderRadius: '8px', padding: '2px 6px', cursor: 'pointer', gap: '4px' }}>
<ColoredIcon src={getReactionIcon(emojiName)} size="16px" color={data.me ? null : '#b9bbbe'} />
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : '#b9bbbe', fontWeight: 600 }}>{data.count}</span>
</div>
))}
</div>
);
};
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 />}
<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%' }} />
{status === 'LoadingMore' && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
<div className="loading-spinner" />
</div>
)}
{status === 'Exhausted' && decryptedMessages.length > 0 && (
<div className="channel-beginning">
<div className="channel-beginning-icon">#</div>
<h1 className="channel-beginning-title">Welcome to #{channelName}</h1>
<p className="channel-beginning-subtitle">This is the start of the #{channelName} channel.</p>
</div>
)}
{status === 'LoadingFirstPage' && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flex: 1, padding: '40px 0' }}>
<div className="loading-spinner" />
</div>
)}
{decryptedMessages.map((msg, idx) => { {decryptedMessages.map((msg, idx) => {
const currentDate = new Date(msg.created_at); const currentDate = new Date(msg.created_at);
const previousDate = idx > 0 ? new Date(decryptedMessages[idx - 1].created_at) : null; const previousDate = idx > 0 ? new Date(decryptedMessages[idx - 1].created_at) : null;
const isNewDay = !previousDate || (currentDate.getDate() !== previousDate.getDate() || currentDate.getMonth() !== previousDate.getMonth() || currentDate.getFullYear() !== previousDate.getFullYear());
let isAttachment = false;
let attachmentMetadata = null;
try { if (msg.content.startsWith('{')) { const parsed = JSON.parse(msg.content); if (parsed.type === 'attachment') { isAttachment = true; attachmentMetadata = parsed; } } } catch (e) {}
const isMentioned = msg.content && msg.content.includes(`@${username}`); const isMentioned = msg.content && msg.content.includes(`@${username}`);
const userColor = getUserColor(msg.username || 'Unknown');
const isOwner = msg.username === username;
const prevMsg = idx > 0 ? decryptedMessages[idx - 1] : null;
const isGrouped = prevMsg
&& prevMsg.username === msg.username
&& !isNewDay(currentDate, previousDate)
&& (currentDate - new Date(prevMsg.created_at)) < 60000;
return ( return (
<React.Fragment key={msg.id || idx}> <React.Fragment key={msg.id || idx}>
{isNewDay && <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" 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: msg.username === username }); }}> <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 }); }}>
{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' }} />}
{isGrouped ? (
<div className="message-avatar-wrapper grouped-timestamp-wrapper">
<span className="grouped-timestamp">
{currentDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
) : (
<div className="message-avatar-wrapper"> <div className="message-avatar-wrapper">
<div className="message-avatar" style={{ backgroundColor: getUserColor(msg.username || 'Unknown') }}> <div className="message-avatar" style={{ backgroundColor: userColor }}>
{(msg.username || '?').substring(0, 1).toUpperCase()} {(msg.username || '?').substring(0, 1).toUpperCase()}
</div> </div>
</div> </div>
)}
<div className="message-body"> <div className="message-body">
{!isGrouped && (
<div className="message-header"> <div className="message-header">
<span className="username" style={{ color: getUserColor(msg.username || 'Unknown') }}>{msg.username || 'Unknown'}</span> <span className="username" style={{ color: userColor }}>{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"> <div className="message-content">
{isAttachment ? <Attachment metadata={attachmentMetadata} onLoad={scrollToBottom} onImageClick={setZoomedImage} /> : ( {renderMessageContent(msg)}
<> {renderReactions(msg)}
{(() => {
const urls = extractUrls(msg.content);
const isOnlyUrl = urls.length === 1 && msg.content.trim() === urls[0];
const isGif = isOnlyUrl && (urls[0].includes('tenor.com') || urls[0].includes('giphy.com') || urls[0].endsWith('.gif'));
if (isGif) return null;
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={(url) => url} components={{
a: ({ node, ...props }) => {
if (props.href && props.href.startsWith('mention://')) return <span style={{ background: 'color-mix(in oklab,hsl(234.935 calc(1*85.556%) 64.706% /0.23921568627450981) 100%,hsl(0 0% 0% /0.23921568627450981) 0%)', borderRadius: '3px', color: 'color-mix(in oklab, hsl(228.14 calc(1*100%) 83.137% /1) 100%, #000 0%)', fontWeight: 500, padding: '0 2px', cursor: 'default' }} title={props.children}>{props.children}</span>;
return <a {...props} onClick={(e) => { e.preventDefault(); window.cryptoAPI.openExternal(props.href); }} style={{ color: '#00b0f4', cursor: 'pointer', textDecoration: 'none' }} onMouseOver={(e) => e.target.style.textDecoration = 'underline'} onMouseOut={(e) => e.target.style.textDecoration = 'none'} />;
},
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? <SyntaxHighlighter style={oneDark} language={match[1]} PreTag="div" {...props}>{String(children).replace(/\n$/, '')}</SyntaxHighlighter> : <code className={className} {...props}>{children}</code>;
},
p: ({ node, ...props }) => <p style={{ margin: '0 0 6px 0' }} {...props} />,
h1: ({ node, ...props }) => <h1 style={{ fontSize: '1.5rem', fontWeight: 700, margin: '12px 0 6px 0', lineHeight: 1.1 }} {...props} />,
h2: ({ node, ...props }) => <h2 style={{ fontSize: '1.25rem', fontWeight: 700, margin: '10px 0 6px 0', lineHeight: 1.1 }} {...props} />,
h3: ({ node, ...props }) => <h3 style={{ fontSize: '1.1rem', fontWeight: 700, margin: '8px 0 6px 0', lineHeight: 1.1 }} {...props} />,
ul: ({ node, ...props }) => <ul style={{ margin: '0 0 6px 0', paddingLeft: '20px' }} {...props} />,
ol: ({ node, ...props }) => <ol style={{ margin: '0 0 6px 0', paddingLeft: '20px' }} {...props} />,
li: ({ node, ...props }) => <li style={{ margin: '0' }} {...props} />,
hr: ({ node, ...props }) => <hr style={{ border: 'none', borderTop: '1px solid #4f545c', margin: '12px 0' }} {...props} />,
img: ({ node, alt, src, ...props }) => {
if (alt && alt.startsWith(':') && alt.endsWith(':')) {
return <img src={src} alt={alt} style={{ width: '22px', height: '22px', verticalAlign: 'bottom', margin: '0 1px', display: 'inline' }} />;
}
return <img alt={alt} src={src} {...props} />;
},
}}>{formatEmojis(formatMentions(msg.content))}</ReactMarkdown>
);
})()}
{extractUrls(msg.content).map((url, i) => <LinkPreview key={i} url={url} />)}
</>
)}
{msg.reactions && Object.keys(msg.reactions).length > 0 && (
<div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}>
{Object.entries(msg.reactions).map(([emojiName, data]) => {
const getIcon = (name) => { switch(name) { case 'thumbsup': return thumbsupIcon; case 'heart': return heartIcon; case 'fire': return fireIcon; default: return heartIcon; } };
return (
<div key={emojiName} onClick={() => handleReactionClick(msg.id, emojiName, data.me)} style={{ display: 'flex', alignItems: 'center', backgroundColor: data.me ? 'rgba(88, 101, 242, 0.15)' : '#2f3136', border: data.me ? '1px solid #5865F2' : '1px solid transparent', borderRadius: '8px', padding: '2px 6px', cursor: 'pointer', gap: '4px' }}>
<ColoredIcon src={getIcon(emojiName)} size="16px" color={data.me ? null : '#b9bbbe'} />
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : '#b9bbbe', fontWeight: 600 }}>{data.count}</span>
</div>
);
})}
</div>
)}
</div> </div>
{hoveredMessageId === msg.id && ( {hoveredMessageId === msg.id && (
<MessageToolbar isOwner={msg.username === username} <MessageToolbar isOwner={isOwner}
onAddReaction={(emoji) => { const emojiName = emoji || 'heart'; addReaction({ messageId: msg.id, userId: currentUserId, emoji: emojiName }); }} onAddReaction={(emoji) => { addReaction({ messageId: msg.id, userId: currentUserId, emoji: emoji || 'heart' }); }}
onEdit={() => console.log('Edit', msg.id)} onEdit={() => console.log('Edit', msg.id)}
onReply={() => console.log('Reply', msg.id)} onReply={() => console.log('Reply', msg.id)}
onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner: msg.username === username }); }} onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner }); }}
/> />
)} )}
</div> </div>
@@ -786,16 +967,15 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
{uploading ? <div className="spinner" style={{ width: 24, height: 24, borderRadius: '50%', border: '2px solid #b9bbbe', borderTopColor: 'transparent', animation: 'spin 1s linear infinite' }}></div> : <ColoredIcon src={AddIcon} color={ICON_COLOR_DEFAULT} size="24px" />} {uploading ? <div className="spinner" style={{ width: 24, height: 24, borderRadius: '50%', border: '2px solid #b9bbbe', borderTopColor: 'transparent', animation: 'spin 1s linear infinite' }}></div> : <ColoredIcon src={AddIcon} color={ICON_COLOR_DEFAULT} size="24px" />}
</button> </button>
<div ref={inputDivRef} contentEditable className="chat-input-richtext" role="textbox" aria-multiline="true" <div ref={inputDivRef} contentEditable className="chat-input-richtext" role="textbox" aria-multiline="true"
onBlur={() => { const sel = window.getSelection(); if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange(); }} onBlur={saveSelection}
onMouseUp={() => { const sel = window.getSelection(); if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange(); }} onMouseUp={saveSelection}
onKeyUp={() => { const sel = window.getSelection(); if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange(); }} onKeyUp={saveSelection}
onInput={(e) => { onInput={(e) => {
setInput(e.currentTarget.textContent); setInput(e.currentTarget.textContent);
setHasImages(e.currentTarget.querySelectorAll('img').length > 0); setHasImages(e.currentTarget.querySelectorAll('img').length > 0);
const text = e.currentTarget.innerText; const text = e.currentTarget.innerText;
setIsMultiline(text.includes('\n') || e.currentTarget.scrollHeight > 50); setIsMultiline(text.includes('\n') || e.currentTarget.scrollHeight > 50);
checkTypedEmoji(); checkTypedEmoji();
// Handle Typing via Convex
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(() => {});
@@ -812,14 +992,14 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
/> />
{!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: '#72767d', pointerEvents: 'none', userSelect: 'none' }}>Message #{channelName || channelId}</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(); if (showGifPicker && pickerActiveTab === 'GIFs') setShowGifPicker(false); else { setPickerActiveTab('GIFs'); setShowGifPicker(true); } }}> <button type="button" className="chat-input-icon-btn" title="GIF" onClick={(e) => { e.stopPropagation(); togglePicker('GIFs'); }}>
<ColoredIcon src={GifIcon} color={(showGifPicker && pickerActiveTab === 'GIFs') ? '#5865f2' : ICON_COLOR_DEFAULT} size="24px" /> <ColoredIcon src={GifIcon} color={pickerTab === 'GIFs' ? '#5865f2' : ICON_COLOR_DEFAULT} size="24px" />
</button> </button>
{showGifPicker && ( {showGifPicker && (
<GifPicker onSelect={(data) => { if (typeof data === 'string') { sendMessage(data); setShowGifPicker(false); } else { insertEmoji(data); setShowGifPicker(false); } }} onClose={() => setShowGifPicker(false)} currentTab={pickerActiveTab} onTabChange={setPickerActiveTab} /> <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> <button type="button" className="chat-input-icon-btn" title="Sticker"><ColoredIcon src={StickerIcon} color={ICON_COLOR_DEFAULT} size="24px" /></button>
<EmojiButton active={showGifPicker && pickerActiveTab === 'Emoji'} onClick={() => { if (showGifPicker && pickerActiveTab === 'Emoji') setShowGifPicker(false); else { setPickerActiveTab('Emoji'); setShowGifPicker(true); } }} /> <EmojiButton active={pickerTab === 'Emoji'} onClick={() => togglePicker('Emoji')} />
</div> </div>
</div> </div>
</form> </form>

View File

@@ -3,44 +3,199 @@ 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';
const EmojiItem = ({ emoji, onSelect }) => (
<div
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })}
title={`:${emoji.name}:`}
style={{ cursor: 'pointer', padding: '4px', borderRadius: '4px' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<img src={emoji.src} alt={emoji.name} style={{ width: '32px', height: '32px' }} loading="lazy" />
</div>
);
const emojiGridStyle = { display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: '4px' };
const GifContent = ({ search, results, categories, onSelect, onCategoryClick }) => {
if (search || results.length > 0) {
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
{results.map(gif => (
<img
key={gif.id}
src={gif.media_formats?.tinygif?.url || gif.media_formats?.gif?.url}
alt={gif.title}
style={{ width: '100%', borderRadius: '4px', cursor: 'pointer' }}
onMouseDown={(e) => e.preventDefault()}
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>}
</div>
);
}
return (
<div>
<div style={{
backgroundImage: 'linear-gradient(to right, #4a90e2, #9013fe)',
borderRadius: '4px',
padding: '20px',
marginBottom: '12px',
color: '#fff',
fontWeight: 'bold',
fontSize: '16px',
textAlign: 'center',
cursor: 'pointer'
}}>
Favorites
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
{categories.map(cat => (
<div
key={cat.name}
onClick={() => onCategoryClick(cat.name)}
style={{
position: 'relative',
height: '100px',
borderRadius: '4px',
overflow: 'hidden',
cursor: 'pointer',
backgroundColor: '#202225'
}}
>
<video
src={cat.src}
autoPlay
loop
muted
style={{ width: '100%', height: '100%', objectFit: 'cover', opacity: 0.6 }}
/>
<div style={{
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.3)',
color: '#fff',
fontWeight: 'bold',
textTransform: 'capitalize'
}}>
{cat.name}
</div>
</div>
))}
</div>
</div>
);
};
const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory }) => {
if (search) {
const filtered = AllEmojis
.filter(e => e.name.toLowerCase().includes(search.toLowerCase().replace(/:/g, '')))
.slice(0, 100);
return (
<div className="emoji-grid" style={{ height: '100%' }}>
<div style={emojiGridStyle}>
{filtered.map((emoji, idx) => (
<EmojiItem key={idx} emoji={emoji} onSelect={onSelect} />
))}
</div>
</div>
);
}
return (
<div className="emoji-grid" style={{ height: '100%' }}>
{Object.entries(CategorizedEmojis).map(([category, emojis]) => (
<div key={category} style={{ marginBottom: '8px' }}>
<div
onClick={() => toggleCategory(category)}
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
marginBottom: '8px',
padding: '4px',
borderRadius: '4px'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="#b9bbbe"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
style={{
marginRight: '8px',
transform: collapsedCategories[category] ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s'
}}
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<h3 style={{
color: '#b9bbbe',
fontSize: '12px',
textTransform: 'uppercase',
fontWeight: 700,
margin: 0
}}>
{category}
</h3>
</div>
{!collapsedCategories[category] && (
<div style={emojiGridStyle}>
{emojis.map((emoji, idx) => (
<EmojiItem key={idx} emoji={emoji} onSelect={onSelect} />
))}
</div>
)}
</div>
))}
</div>
);
};
const initialCollapsed = Object.fromEntries(
Object.keys(CategorizedEmojis).map(cat => [cat, true])
);
const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) => { const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) => {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [results, setResults] = useState([]); const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [internalActiveTab, setInternalActiveTab] = useState(initialTab || 'GIFs'); const [internalActiveTab, setInternalActiveTab] = useState(initialTab || 'GIFs');
const [collapsedCategories, setCollapsedCategories] = useState(initialCollapsed);
const inputRef = useRef(null);
const convex = useConvex();
// Resolve effective active tab
const activeTab = currentTab !== undefined ? currentTab : internalActiveTab; const activeTab = currentTab !== undefined ? currentTab : internalActiveTab;
const setActiveTab = (tab) => { const setActiveTab = (tab) => {
if (onTabChange) onTabChange(tab); if (onTabChange) onTabChange(tab);
if (currentTab === undefined) setInternalActiveTab(tab); if (currentTab === undefined) setInternalActiveTab(tab);
}; };
const [emojiCategories, setEmojiCategories] = useState({});
const [collapsedCategories, setCollapsedCategories] = useState({});
const inputRef = useRef(null);
const convex = useConvex();
useEffect(() => { useEffect(() => {
// Fetch categories via Convex action
convex.action(api.gifs.categories, {}) convex.action(api.gifs.categories, {})
.then(data => { .then(data => {
if (data.categories) setCategories(data.categories); if (data.categories) setCategories(data.categories);
}) })
.catch(err => console.error('Failed to load categories', err)); .catch(err => console.error('Failed to load categories', err));
// Auto focus if (inputRef.current) inputRef.current.focus();
if(inputRef.current) inputRef.current.focus();
// Load Emoji categories
setEmojiCategories(CategorizedEmojis);
// Initialize collapsed state (all true)
const initialCollapsed = {};
Object.keys(CategorizedEmojis).forEach(cat => initialCollapsed[cat] = true);
setCollapsedCategories(initialCollapsed);
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -64,10 +219,6 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, [search, activeTab]); }, [search, activeTab]);
const handleCategoryClick = (categoryName) => {
setSearch(categoryName);
};
const toggleCategory = (categoryName) => { const toggleCategory = (categoryName) => {
setCollapsedCategories(prev => ({ setCollapsedCategories(prev => ({
...prev, ...prev,
@@ -161,170 +312,9 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
{loading ? ( {loading ? (
<div style={{ color: '#b9bbbe', textAlign: 'center', padding: '20px' }}>Loading...</div> <div style={{ color: '#b9bbbe', textAlign: 'center', padding: '20px' }}>Loading...</div>
) : activeTab === 'GIFs' ? ( ) : activeTab === 'GIFs' ? (
// GIF Tab Logic (Search Results OR Categories) <GifContent search={search} results={results} categories={categories} onSelect={onSelect} onCategoryClick={setSearch} />
(search || results.length > 0) ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
{results.map(gif => (
<img
key={gif.id}
src={gif.media_formats?.tinygif?.url || gif.media_formats?.gif?.url}
alt={gif.title}
style={{ width: '100%', borderRadius: '4px', cursor: 'pointer' }}
onMouseDown={(e) => e.preventDefault()}
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>}
</div>
) : ( ) : (
// GIF Categories <EmojiContent search={search} onSelect={onSelect} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} />
<div>
<div style={{
backgroundImage: 'linear-gradient(to right, #4a90e2, #9013fe)',
borderRadius: '4px',
padding: '20px',
marginBottom: '12px',
color: '#fff',
fontWeight: 'bold',
fontSize: '16px',
textAlign: 'center',
cursor: 'pointer'
}}>
Favorites
</div>
{/* Grid of Categories */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
{categories.map(cat => (
<div
key={cat.name}
onClick={() => handleCategoryClick(cat.name)}
style={{
position: 'relative',
height: '100px',
borderRadius: '4px',
overflow: 'hidden',
cursor: 'pointer',
backgroundColor: '#202225'
}}
>
<video
src={cat.src}
autoPlay
loop
muted
style={{ width: '100%', height: '100%', objectFit: 'cover', opacity: 0.6 }}
/>
<div style={{
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.3)',
color: '#fff',
fontWeight: 'bold',
textTransform: 'capitalize'
}}>
{cat.name}
</div>
</div>
))}
</div>
</div>
)
) : (
// Emoji / Other Tabs
<div className="emoji-grid" style={{ height: '100%' }}>
{search ? (
// Emoji Search Results
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: '4px' }}>
{AllEmojis.filter(e => e.name.toLowerCase().includes(search.toLowerCase().replace(/:/g, '')))
.slice(0, 100)
.map((emoji, idx) => (
<div
key={idx}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })}
title={`:${emoji.name}:`}
style={{ cursor: 'pointer', padding: '4px', borderRadius: '4px' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<img src={emoji.src} alt={emoji.name} style={{ width: '32px', height: '32px' }} />
</div>
))}
</div>
) : (
// Emoji Categories
Object.entries(emojiCategories).map(([category, emojis]) => (
<div key={category} style={{ marginBottom: '8px' }}>
<div
onClick={() => toggleCategory(category)}
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
marginBottom: '8px',
padding: '4px',
borderRadius: '4px'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="#b9bbbe"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
style={{
marginRight: '8px',
transform: collapsedCategories[category] ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s'
}}
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<h3 style={{
color: '#b9bbbe',
fontSize: '12px',
textTransform: 'uppercase',
fontWeight: 700,
margin: 0
}}>
{category}
</h3>
</div>
{!collapsedCategories[category] && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: '4px' }}>
{emojis.map((emoji, idx) => (
<div
key={idx}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })}
title={`:${emoji.name}:`}
style={{ cursor: 'pointer', padding: '4px', borderRadius: '4px' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<img
src={emoji.src}
alt={emoji.name}
style={{ width: '32px', height: '32px' }}
loading="lazy"
/>
</div>
))}
</div>
)}
</div>
))
)}
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState } 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';
@@ -51,13 +51,9 @@ const ServerSettingsModal = ({ onClose }) => {
}; };
const handleAssignRole = async (roleId, targetUserId, isAdding) => { const handleAssignRole = async (roleId, targetUserId, isAdding) => {
const action = isAdding ? api.roles.assign : api.roles.unassign;
try { try {
if (isAdding) { await convex.mutation(action, { roleId, userId: targetUserId });
await convex.mutation(api.roles.assign, { roleId, userId: targetUserId });
} else {
await convex.mutation(api.roles.unassign, { roleId, userId: targetUserId });
}
// Convex reactive queries auto-update members list
} catch (e) { } catch (e) {
console.error('Failed to assign/unassign role:', e); console.error('Failed to assign/unassign role:', e);
} }
@@ -92,17 +88,21 @@ const ServerSettingsModal = ({ onClose }) => {
</div> </div>
); );
const canManageRoles = myPermissions.manage_roles;
const disabledOpacity = canManageRoles ? 1 : 0.5;
const labelStyle = { display: 'block', color: '#b9bbbe', fontSize: '12px', fontWeight: '700', marginBottom: 8 };
const editableRoles = roles.filter(r => r.name !== 'Owner');
const renderRolesTab = () => ( const renderRolesTab = () => (
<div style={{ display: 'flex', height: '100%' }}> <div style={{ display: 'flex', height: '100%' }}>
{/* Role List */}
<div style={{ width: '200px', borderRight: '1px solid #3f4147', marginRight: '20px' }}> <div style={{ width: '200px', borderRight: '1px solid #3f4147', 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: '#b9bbbe', fontSize: '12px' }}>ROLES</h3>
{myPermissions.manage_roles && ( {canManageRoles && (
<button onClick={handleCreateRole} style={{ background: 'transparent', border: 'none', color: '#b9bbbe', cursor: 'pointer' }}>+</button> <button onClick={handleCreateRole} style={{ background: 'transparent', border: 'none', color: '#b9bbbe', cursor: 'pointer' }}>+</button>
)} )}
</div> </div>
{roles.filter(r => r.name !== 'Owner').map(r => ( {editableRoles.map(r => (
<div <div
key={r._id} key={r._id}
onClick={() => setSelectedRole(r)} onClick={() => setSelectedRole(r)}
@@ -119,29 +119,28 @@ const ServerSettingsModal = ({ onClose }) => {
))} ))}
</div> </div>
{/* Edit Panel */}
{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: 'white', marginTop: 0 }}>Edit Role - {selectedRole.name}</h2>
<label style={{ display: 'block', color: '#b9bbbe', fontSize: '12px', fontWeight: '700', marginBottom: 8 }}>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={!myPermissions.manage_roles} disabled={!canManageRoles}
style={{ width: '100%', padding: 10, background: '#202225', border: 'none', borderRadius: 4, color: 'white', marginBottom: 20, opacity: !myPermissions.manage_roles ? 0.5 : 1 }} style={{ width: '100%', padding: 10, background: '#202225', border: 'none', borderRadius: 4, color: 'white', marginBottom: 20, opacity: disabledOpacity }}
/> />
<label style={{ display: 'block', color: '#b9bbbe', fontSize: '12px', fontWeight: '700', marginBottom: 8 }}>ROLE COLOR</label> <label style={labelStyle}>ROLE COLOR</label>
<input <input
type="color" type="color"
value={selectedRole.color} value={selectedRole.color}
onChange={(e) => handleUpdateRole(selectedRole._id, { color: e.target.value })} onChange={(e) => handleUpdateRole(selectedRole._id, { color: e.target.value })}
disabled={!myPermissions.manage_roles} disabled={!canManageRoles}
style={{ width: '100%', height: 40, border: 'none', padding: 0, marginBottom: 20, opacity: !myPermissions.manage_roles ? 0.5 : 1 }} style={{ width: '100%', height: 40, border: 'none', padding: 0, marginBottom: 20, opacity: disabledOpacity }}
/> />
<label style={{ display: 'block', color: '#b9bbbe', fontSize: '12px', fontWeight: '700', marginBottom: 8 }}>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 #3f4147' }}>
<span style={{ color: 'white', textTransform: 'capitalize' }}>{perm.replace('_', ' ')}</span> <span style={{ color: 'white', textTransform: 'capitalize' }}>{perm.replace('_', ' ')}</span>
@@ -149,17 +148,17 @@ const ServerSettingsModal = ({ onClose }) => {
type="checkbox" type="checkbox"
checked={selectedRole.permissions?.[perm] || false} checked={selectedRole.permissions?.[perm] || false}
onChange={(e) => { onChange={(e) => {
const newPerms = { ...selectedRole.permissions, [perm]: e.target.checked }; handleUpdateRole(selectedRole._id, {
handleUpdateRole(selectedRole._id, { permissions: newPerms }); permissions: { ...selectedRole.permissions, [perm]: e.target.checked }
});
}} }}
disabled={!myPermissions.manage_roles} disabled={!canManageRoles}
style={{ transform: 'scale(1.5)', opacity: !myPermissions.manage_roles ? 0.5 : 1 }} style={{ transform: 'scale(1.5)', opacity: disabledOpacity }}
/> />
</div> </div>
))} ))}
{/* Prevent deleting Default Roles */} {canManageRoles && selectedRole.name !== 'Owner' && selectedRole.name !== '@everyone' && (
{myPermissions.manage_roles && selectedRole.name !== 'Owner' && selectedRole.name !== '@everyone' && (
<button onClick={() => handleDeleteRole(selectedRole._id)} style={{ color: '#ed4245', background: 'transparent', border: '1px solid #ed4245', padding: '6px 12px', borderRadius: 4, marginTop: 20, cursor: 'pointer' }}> <button onClick={() => handleDeleteRole(selectedRole._id)} style={{ color: '#ed4245', background: 'transparent', border: '1px solid #ed4245', padding: '6px 12px', borderRadius: 4, marginTop: 20, cursor: 'pointer' }}>
Delete Role Delete Role
</button> </button>
@@ -182,18 +181,18 @@ const ServerSettingsModal = ({ onClose }) => {
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ color: 'white', fontWeight: 'bold' }}>{m.username}</div> <div style={{ color: 'white', fontWeight: 'bold' }}>{m.username}</div>
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
{m.roles && 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: 'white', padding: '2px 4px', borderRadius: 4 }}>
{r.name} {r.name}
</span> </span>
))} ))}
</div> </div>
</div> </div>
{canManageRoles && (
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
{roles.filter(r => r.name !== 'Owner').map(r => { {editableRoles.map(r => {
const hasRole = m.roles?.some(ur => ur._id === r._id); const hasRole = m.roles?.some(ur => ur._id === r._id);
return ( return (
myPermissions.manage_roles && (
<button <button
key={r._id} key={r._id}
onClick={() => handleAssignRole(r._id, m.id, !hasRole)} onClick={() => handleAssignRole(r._id, m.id, !hasRole)}
@@ -205,15 +204,23 @@ const ServerSettingsModal = ({ onClose }) => {
}} }}
title={hasRole ? `Remove ${r.name}` : `Add ${r.name}`} title={hasRole ? `Remove ${r.name}` : `Add ${r.name}`}
/> />
)
); );
})} })}
</div> </div>
)}
</div> </div>
))} ))}
</div> </div>
); );
const renderTabContent = () => {
switch (activeTab) {
case 'Roles': return renderRolesTab();
case 'Members': return renderMembersTab();
default: return <div style={{ color: '#b9bbbe' }}>Server Name: Secure Chat<br/>Region: US-East</div>;
}
};
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: 'hsl(240 calc(1*7.143%) 10.98% /1)', zIndex: 1000, display: 'flex', color: '#dcddde' }}>
{renderSidebar()} {renderSidebar()}
@@ -222,9 +229,7 @@ const ServerSettingsModal = ({ onClose }) => {
<h2 style={{ color: 'white', margin: 0 }}>{activeTab}</h2> <h2 style={{ color: 'white', margin: 0 }}>{activeTab}</h2>
<button onClick={onClose} style={{ background: 'transparent', border: '1px solid #b9bbbe', borderRadius: '50%', width: 36, height: 36, color: '#b9bbbe', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}></button> <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>
</div> </div>
{activeTab === 'Roles' && renderRolesTab()} {renderTabContent()}
{activeTab === 'Members' && renderMembersTab()}
{activeTab === 'Overview' && <div style={{ color: '#b9bbbe' }}>Server Name: Secure Chat<br/>Region: US-East</div>}
</div> </div>
</div> </div>
); );

View File

@@ -17,7 +17,40 @@ import disconnectIcon from '../assets/icons/disconnect.svg';
import cameraIcon from '../assets/icons/camera.svg'; import cameraIcon from '../assets/icons/camera.svg';
import screenIcon from '../assets/icons/screen.svg'; import screenIcon from '../assets/icons/screen.svg';
// Helper Component for coloring SVGs const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)';
const ICON_COLOR_ACTIVE = 'hsl(357.692 67.826% 54.902% / 1)';
const controlButtonStyle = {
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '6px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
};
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];
}
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
function randomHex(length) {
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return bytesToHex(bytes);
}
const ColoredIcon = ({ src, color, size = '20px' }) => ( const ColoredIcon = ({ src, color, size = '20px' }) => (
<div style={{ <div style={{
width: size, width: size,
@@ -46,18 +79,6 @@ const UserControlPanel = ({ username }) => {
const effectiveMute = isMuted || isDeafened; const effectiveMute = isMuted || isDeafened;
const getUserColor = (name) => {
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
};
const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)';
const ICON_COLOR_ACTIVE = 'hsl(357.692 67.826% 54.902% / 1)';
return ( return (
<div style={{ <div style={{
height: '64px', height: '64px',
@@ -94,7 +115,6 @@ const UserControlPanel = ({ username }) => {
}}> }}>
{(username || '?').substring(0, 1).toUpperCase()} {(username || '?').substring(0, 1).toUpperCase()}
</div> </div>
{/* Status Indicator */}
<div style={{ <div style={{
position: 'absolute', position: 'absolute',
bottom: '-2px', bottom: '-2px',
@@ -118,57 +138,19 @@ const UserControlPanel = ({ username }) => {
{/* Controls */} {/* Controls */}
<div style={{ display: 'flex' }}> <div style={{ display: 'flex' }}>
<button <button onClick={toggleMute} title={effectiveMute ? "Unmute" : "Mute"} style={controlButtonStyle}>
onClick={toggleMute}
title={effectiveMute ? "Unmute" : "Mute"}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '6px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<ColoredIcon <ColoredIcon
src={effectiveMute ? mutedIcon : muteIcon} src={effectiveMute ? mutedIcon : muteIcon}
color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT} color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
/> />
</button> </button>
<button <button onClick={toggleDeafen} title={isDeafened ? "Undeafen" : "Deafen"} style={controlButtonStyle}>
onClick={toggleDeafen}
title={isDeafened ? "Undeafen" : "Deafen"}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '6px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<ColoredIcon <ColoredIcon
src={isDeafened ? defeanedIcon : defeanIcon} src={isDeafened ? defeanedIcon : defeanIcon}
color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT} color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
/> />
</button> </button>
<button <button title="User Settings" style={controlButtonStyle}>
title="User Settings"
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '6px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<ColoredIcon <ColoredIcon
src={settingsIcon} src={settingsIcon}
color={ICON_COLOR_DEFAULT} color={ICON_COLOR_DEFAULT}
@@ -181,6 +163,92 @@ const UserControlPanel = ({ username }) => {
const headerButtonStyle = {
background: 'transparent',
border: 'none',
color: '#b9bbbe',
cursor: 'pointer',
fontSize: '18px',
padding: '0 4px'
};
const voicePanelButtonStyle = {
flex: 1,
alignItems: 'center',
minHeight: '32px',
background: 'hsl(240 calc(1*4%) 60.784% /0.0784313725490196)',
border: 'hsl(0 calc(1*0%) 100% /0.0784313725490196)',
borderColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.0392156862745098) 100%,hsl(0 0% 0% /0.0392156862745098) 0%)',
borderRadius: '8px',
cursor: 'pointer',
padding: '4px',
display: 'flex',
justifyContent: 'center'
};
const liveBadgeStyle = {
backgroundColor: '#ed4245',
borderRadius: '8px',
padding: '0 6px',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
textAlign: 'center',
height: '16px',
minHeight: '16px',
minWidth: '16px',
color: 'hsl(0 calc(1*0%) 100% /1)',
fontSize: '12px',
fontWeight: '700',
letterSpacing: '.02em',
lineHeight: '1.3333333333333333',
textTransform: 'uppercase',
display: 'flex',
alignItems: 'center',
marginRight: '4px'
};
const ACTIVE_SPEAKER_SHADOW = '0 0 0 0px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 2px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 3px color-mix(in oklab, hsl(240 calc(1*7.143%) 10.98% /1) 100%, #000 0%)';
const VOICE_ACTIVE_COLOR = "color-mix(in oklab, hsl(132.809 calc(1*34.902%) 50% /1) 100%, #000 0%)";
async function encryptKeyForUsers(convex, channelId, keyHex) {
const users = await convex.query(api.auth.getPublicKeys, {});
const batchKeys = [];
for (const u of users) {
if (!u.public_identity_key) continue;
try {
const payload = JSON.stringify({ [channelId]: keyHex });
const encryptedKeyHex = await window.cryptoAPI.publicEncrypt(u.public_identity_key, payload);
batchKeys.push({
channelId,
userId: u.id,
encryptedKeyBundle: encryptedKeyHex,
keyVersion: 1
});
} catch (e) {
console.error("Failed to encrypt for user", u.id, e);
}
}
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
}
function getScreenCaptureConstraints(selection) {
if (selection.type === 'device') {
return { video: { deviceId: { exact: selection.deviceId } }, audio: false };
}
return {
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: selection.sourceId
}
}
};
}
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 }) => {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false); const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
@@ -191,14 +259,10 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
const convex = useConvex(); const convex = useConvex();
// Callbacks for Modal - Convex is reactive, no need to manually refresh const onRenameChannel = () => {};
const onRenameChannel = (id, newName) => {
// Convex reactive queries auto-update
};
const onDeleteChannel = (id) => { const onDeleteChannel = (id) => {
if (activeChannel === id) onSelectChannel(null); if (activeChannel === id) onSelectChannel(null);
// Convex reactive queries auto-update
}; };
const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing } = useVoice(); const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing } = useVoice();
@@ -211,13 +275,13 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
const handleSubmitCreate = async (e) => { const handleSubmitCreate = async (e) => {
if (e) e.preventDefault(); if (e) e.preventDefault();
if (!newChannelName.trim()) { if (!newChannelName.trim()) {
setIsCreating(false); setIsCreating(false);
return; return;
} }
const name = newChannelName.trim(); const name = newChannelName.trim();
const type = newChannelType;
const userId = localStorage.getItem('userId'); const userId = localStorage.getItem('userId');
if (!userId) { if (!userId) {
@@ -227,54 +291,19 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
} }
try { try {
// 1. Create Channel via Convex const { id: channelId } = await convex.mutation(api.channels.create, { name, type: newChannelType });
const { id: channelId } = await convex.mutation(api.channels.create, { name, type }); const keyHex = randomHex(32);
// 2. Generate Key
const keyBytes = new Uint8Array(32);
crypto.getRandomValues(keyBytes);
const keyHex = Array.from(keyBytes).map(b => b.toString(16).padStart(2, '0')).join('');
// 3. Encrypt Key for ALL Users
try {
const users = await convex.query(api.auth.getPublicKeys, {});
const batchKeys = [];
for (const u of users) {
if (!u.public_identity_key) continue;
try { try {
const payload = JSON.stringify({ [channelId]: keyHex }); await encryptKeyForUsers(convex, channelId, keyHex);
const encryptedKeyHex = await window.cryptoAPI.publicEncrypt(u.public_identity_key, payload);
batchKeys.push({
channelId,
userId: u.id,
encryptedKeyBundle: encryptedKeyHex,
keyVersion: 1
});
} catch (e) {
console.error("Failed to encrypt for user", u.id, e);
}
}
// 4. Upload Keys Batch via Convex
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
// No need to notify - Convex queries are reactive!
} catch (keyErr) { } catch (keyErr) {
console.error("Critical: Failed to distribute keys", keyErr); console.error("Critical: Failed to distribute keys", keyErr);
alert("Channel created but key distribution failed."); alert("Channel created but key distribution failed.");
} }
// 5. Done - Convex reactive queries auto-update the channel list
setIsCreating(false);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert("Failed to create channel: " + err.message); alert("Failed to create channel: " + err.message);
} finally {
setIsCreating(false); setIsCreating(false);
} }
}; };
@@ -286,14 +315,6 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
return; return;
} }
try {
// 1. Generate Invite Code & Secret
const inviteCode = crypto.randomUUID();
const inviteSecretBytes = new Uint8Array(32);
crypto.getRandomValues(inviteSecretBytes);
const inviteSecret = Array.from(inviteSecretBytes).map(b => b.toString(16).padStart(2, '0')).join('');
// 2. Prepare Key Bundle
const generalChannel = channels.find(c => c.name === 'general'); const generalChannel = channels.find(c => c.name === 'general');
const targetChannelId = generalChannel ? generalChannel._id : activeChannel; const targetChannelId = generalChannel ? generalChannel._id : activeChannel;
@@ -302,27 +323,21 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
return; return;
} }
const targetKey = channelKeys ? channelKeys[targetChannelId] : null; const targetKey = channelKeys?.[targetChannelId];
if (!targetKey) { if (!targetKey) {
alert("Error: You don't have the key for this channel yet, so you can't invite others."); alert("Error: You don't have the key for this channel yet, so you can't invite others.");
return; return;
} }
const payload = JSON.stringify({ try {
[targetChannelId]: targetKey const inviteCode = crypto.randomUUID();
}); const inviteSecret = randomHex(32);
// 3. Encrypt Payload const payload = JSON.stringify({ [targetChannelId]: targetKey });
const encrypted = await window.cryptoAPI.encryptData(payload, inviteSecret); const encrypted = await window.cryptoAPI.encryptData(payload, inviteSecret);
const blob = JSON.stringify({ c: encrypted.content, t: encrypted.tag, iv: encrypted.iv });
const blob = JSON.stringify({
c: encrypted.content,
t: encrypted.tag,
iv: encrypted.iv
});
// 4. Create invite via Convex
await convex.mutation(api.invites.create, { await convex.mutation(api.invites.create, {
code: inviteCode, code: inviteCode,
encryptedPayload: blob, encryptedPayload: blob,
@@ -330,50 +345,27 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
keyVersion: 1 keyVersion: 1
}); });
// 5. Show Link
const link = `http://localhost:5173/#/register?code=${inviteCode}&key=${inviteSecret}`; const link = `http://localhost:5173/#/register?code=${inviteCode}&key=${inviteSecret}`;
navigator.clipboard.writeText(link); navigator.clipboard.writeText(link);
alert(`Invite Link Copied to Clipboard!\n\n${link}`); alert(`Invite Link Copied to Clipboard!\n\n${link}`);
} catch (e) { } catch (e) {
console.error("Invite Error:", e); console.error("Invite Error:", e);
alert("Failed to create invite. See console."); alert("Failed to create invite. See console.");
} }
}; };
// Screen Share Handler
const handleScreenShareSelect = async (selection) => { const handleScreenShareSelect = async (selection) => {
if (!room) return; if (!room) return;
try { try {
// Unpublish existing screen share if any
if (room.localParticipant.isScreenShareEnabled) { if (room.localParticipant.isScreenShareEnabled) {
await room.localParticipant.setScreenShareEnabled(false); await room.localParticipant.setScreenShareEnabled(false);
} }
// Capture based on selection const stream = await navigator.mediaDevices.getUserMedia(getScreenCaptureConstraints(selection));
let stream;
if (selection.type === 'device') {
stream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: selection.deviceId } },
audio: false
});
} else {
// Electron Screen/Window
stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: selection.sourceId
}
}
});
}
// Publish the video track
const track = stream.getVideoTracks()[0]; const track = stream.getVideoTracks()[0];
if (track) { if (!track) return;
await room.localParticipant.publishTrack(track, { await room.localParticipant.publishTrack(track, {
name: 'screen_share', name: 'screen_share',
source: Track.Source.ScreenShare source: Track.Source.ScreenShare
@@ -385,15 +377,12 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
setScreenSharing(false); setScreenSharing(false);
room.localParticipant.setScreenShareEnabled(false).catch(console.error); room.localParticipant.setScreenShareEnabled(false).catch(console.error);
}; };
}
} catch (err) { } catch (err) {
console.error("Error sharing screen:", err); console.error("Error sharing screen:", err);
alert("Failed to share screen: " + err.message); alert("Failed to share screen: " + err.message);
} }
}; };
// Toggle Modal instead of direct toggle
const handleScreenShareClick = () => { const handleScreenShareClick = () => {
if (room?.localParticipant.isScreenShareEnabled) { if (room?.localParticipant.isScreenShareEnabled) {
room.localParticipant.setScreenShareEnabled(false); room.localParticipant.setScreenShareEnabled(false);
@@ -403,52 +392,59 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
} }
}; };
const handleChannelClick = (channel) => {
if (channel.type === 'voice' && voiceChannelId !== channel._id) {
connectToVoice(channel._id, channel.name, localStorage.getItem('userId'));
} else {
onSelectChannel(channel._id);
}
};
return ( const renderDMView = () => (
<div className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div className="server-list">
{/* Home Button */}
<div
className={`server-icon ${view === 'me' ? 'active' : ''}`}
onClick={() => onViewChange('me')}
style={{
backgroundColor: view === 'me' ? '#5865F2' : '#36393f',
color: view === 'me' ? '#fff' : '#dcddde',
marginBottom: '8px',
cursor: 'pointer'
}}
>
<svg width="28" height="20" viewBox="0 0 28 20">
<path fill="currentColor" d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.0172 1.11817 13.0907 1.11817 11.1372 1.4184C10.9331 0.934541 10.7018 0.461742 10.4496 0C8.59007 0.318797 6.78726 0.879656 5.0768 1.67671C1.65217 6.84883 0.706797 11.8997 1.166 16.858C3.39578 18.5135 5.56066 19.5165 7.6473 20.1417C8.16912 19.4246 8.63212 18.6713 9.02347 17.8863C8.25707 17.5929 7.52187 17.2415 6.82914 16.8374C7.0147 16.6975 7.19515 16.5518 7.36941 16.402C11.66 18.396 16.4523 18.396 20.7186 16.402C20.8953 16.5518 21.0758 16.6975 21.2613 16.8374C20.5663 17.2435 19.8288 17.5947 19.0624 17.8863C19.4537 18.6713 19.9167 19.4246 20.4385 20.1417C22.5276 19.5165 24.6925 18.5135 26.9223 16.858C27.4684 11.2365 25.9961 6.22055 23.0212 1.67671ZM9.68041 13.6383C8.39754 13.6383 7.34085 12.4456 7.34085 10.994C7.34085 9.5424 8.37555 8.34973 9.68041 8.34973C10.9893 8.34973 12.0395 9.5424 12.0175 10.994C12.0175 12.4456 10.9893 13.6383 9.68041 13.6383ZM18.3161 13.6383C17.0332 13.6383 15.9765 12.4456 15.9765 10.994C15.9765 9.5424 17.0112 8.34973 18.3161 8.34973C19.625 8.34973 20.6752 9.5424 20.6532 10.994C20.6532 12.4456 19.625 13.6383 18.3161 13.6383Z"/>
</svg>
</div>
{/* The Server Icon (Secure Chat) */}
<div
className={`server-icon ${view === 'server' ? 'active' : ''}`}
onClick={() => onViewChange('server')}
style={{ cursor: 'pointer' }}
>Sc</div>
</div>
{/* Channel List Area */}
{view === 'me' ? (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}> <div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<DMList <DMList
dmChannels={dmChannels} dmChannels={dmChannels}
activeDMChannel={activeDMChannel} activeDMChannel={activeDMChannel}
onSelectDM={(dm) => { onSelectDM={(dm) => setActiveDMChannel(dm === 'friends' ? null : dm)}
if (dm === 'friends') {
setActiveDMChannel(null);
} else {
setActiveDMChannel(dm);
}
}}
onOpenDM={onOpenDM} onOpenDM={onOpenDM}
/> />
</div> </div>
) : ( );
const renderVoiceUsers = (channel) => {
const users = voiceStates[channel._id];
if (channel.type !== 'voice' || !users?.length) return null;
return (
<div style={{ marginLeft: 32, marginBottom: 8 }}>
{users.map(user => (
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
<div style={{
width: 24, height: 24, borderRadius: '50%',
backgroundColor: '#5865F2',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginRight: 8, fontSize: 10, color: 'white',
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
}}>
{user.username.substring(0, 1).toUpperCase()}
</div>
<span style={{ color: '#b9bbbe', fontSize: 14 }}>{user.username}</span>
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center' }}>
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
{(user.isMuted || user.isDeafened) && (
<ColoredIcon src={mutedIcon} color="#b9bbbe" size="14px" />
)}
{user.isDeafened && (
<ColoredIcon src={defeanedIcon} color="#b9bbbe" size="14px" />
)}
</div>
</div>
))}
</div>
);
};
const renderServerView = () => (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}> <div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
<div className="channel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div className="channel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span <span
@@ -459,39 +455,15 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
Secure Chat Secure Chat
</span> </span>
<div style={{ display: 'flex', gap: '8px' }}> <div style={{ display: 'flex', gap: '8px' }}>
<button <button onClick={handleStartCreate} title="Create New Channel" style={{ ...headerButtonStyle, marginRight: '4px' }}>
onClick={handleStartCreate}
title="Create New Channel"
style={{
background: 'transparent',
border: 'none',
color: '#b9bbbe',
cursor: 'pointer',
fontSize: '18px',
padding: '0 4px',
marginRight: '4px'
}}
>
+ +
</button> </button>
<button <button onClick={handleCreateInvite} title="Create Invite Link" style={headerButtonStyle}>
onClick={handleCreateInvite}
title="Create Invite Link"
style={{
background: 'transparent',
border: 'none',
color: '#b9bbbe',
cursor: 'pointer',
fontSize: '18px',
padding: '0 4px'
}}
>
🔗 🔗
</button> </button>
</div> </div>
</div> </div>
{/* Inline Create Channel Input */}
{isCreating && ( {isCreating && (
<div style={{ padding: '0 8px', marginBottom: '4px' }}> <div style={{ padding: '0 8px', marginBottom: '4px' }}>
<form onSubmit={handleSubmitCreate}> <form onSubmit={handleSubmitCreate}>
@@ -531,17 +503,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
<React.Fragment key={channel._id}> <React.Fragment key={channel._id}>
<div <div
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`} className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
onClick={() => { onClick={() => handleChannelClick(channel)}
if (channel.type === 'voice') {
if (voiceChannelId === channel._id) {
onSelectChannel(channel._id);
} else {
connectToVoice(channel._id, channel.name, localStorage.getItem('userId'));
}
} else {
onSelectChannel(channel._id);
}
}}
style={{ style={{
position: 'relative', position: 'relative',
display: 'flex', display: 'flex',
@@ -556,10 +518,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
<ColoredIcon <ColoredIcon
src={voiceIcon} src={voiceIcon}
size="16px" size="16px"
color={voiceStates[channel._id]?.length > 0 color={voiceStates[channel._id]?.length > 0 ? VOICE_ACTIVE_COLOR : "#8e9297"}
? "color-mix(in oklab, hsl(132.809 calc(1*34.902%) 50% /1) 100%, #000 0%)"
: "#8e9297"
}
/> />
</div> </div>
) : ( ) : (
@@ -589,65 +548,41 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</button> </button>
</div> </div>
{channel.type === 'voice' && voiceStates[channel._id] && voiceStates[channel._id].length > 0 && ( {renderVoiceUsers(channel)}
<div style={{ marginLeft: 32, marginBottom: 8 }}>
{voiceStates[channel._id].map(user => (
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
<div style={{
width: 24, height: 24, borderRadius: '50%',
backgroundColor: '#5865F2',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginRight: 8, fontSize: 10, color: 'white',
boxShadow: activeSpeakers.has(user.userId)
? '0 0 0 0px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 2px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 3px color-mix(in oklab, hsl(240 calc(1*7.143%) 10.98% /1) 100%, #000 0%)'
: 'none'
}}>
{user.username.substring(0, 1).toUpperCase()}
</div>
<span style={{ color: '#b9bbbe', fontSize: 14 }}>{user.username}</span>
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center' }}>
{user.isScreenSharing && (
<div style={{
backgroundColor: '#ed4245',
borderRadius: '8px',
padding: '0 6px',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
textAlign: 'center',
height: '16px',
minHeight: '16px',
minWidth: '16px',
color: 'hsl(0 calc(1*0%) 100% /1)',
fontSize: '12px',
fontWeight: '700',
letterSpacing: '.02em',
lineHeight: '1.3333333333333333',
textTransform: 'uppercase',
display: 'flex',
alignItems: 'center',
marginRight: '4px'
}}>
Live
</div>
)}
{(user.isMuted || user.isDeafened) && (
<ColoredIcon src={mutedIcon} color="#b9bbbe" size="14px" />
)}
{user.isDeafened && (
<ColoredIcon src={defeanedIcon} color="#b9bbbe" size="14px" />
)}
</div>
</div>
))}
</div>
)}
</React.Fragment> </React.Fragment>
))} ))}
</div> </div>
)} );
return (
<div className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div className="server-list">
<div
className={`server-icon ${view === 'me' ? 'active' : ''}`}
onClick={() => onViewChange('me')}
style={{
backgroundColor: view === 'me' ? '#5865F2' : '#36393f',
color: view === 'me' ? '#fff' : '#dcddde',
marginBottom: '8px',
cursor: 'pointer'
}}
>
<svg width="28" height="20" viewBox="0 0 28 20">
<path fill="currentColor" d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.0172 1.11817 13.0907 1.11817 11.1372 1.4184C10.9331 0.934541 10.7018 0.461742 10.4496 0C8.59007 0.318797 6.78726 0.879656 5.0768 1.67671C1.65217 6.84883 0.706797 11.8997 1.166 16.858C3.39578 18.5135 5.56066 19.5165 7.6473 20.1417C8.16912 19.4246 8.63212 18.6713 9.02347 17.8863C8.25707 17.5929 7.52187 17.2415 6.82914 16.8374C7.0147 16.6975 7.19515 16.5518 7.36941 16.402C11.66 18.396 16.4523 18.396 20.7186 16.402C20.8953 16.5518 21.0758 16.6975 21.2613 16.8374C20.5663 17.2435 19.8288 17.5947 19.0624 17.8863C19.4537 18.6713 19.9167 19.4246 20.4385 20.1417C22.5276 19.5165 24.6925 18.5135 26.9223 16.858C27.4684 11.2365 25.9961 6.22055 23.0212 1.67671ZM9.68041 13.6383C8.39754 13.6383 7.34085 12.4456 7.34085 10.994C7.34085 9.5424 8.37555 8.34973 9.68041 8.34973C10.9893 8.34973 12.0395 9.5424 12.0175 10.994C12.0175 12.4456 10.9893 13.6383 9.68041 13.6383ZM18.3161 13.6383C17.0332 13.6383 15.9765 12.4456 15.9765 10.994C15.9765 9.5424 17.0112 8.34973 18.3161 8.34973C19.625 8.34973 20.6752 9.5424 20.6532 10.994C20.6532 12.4456 19.625 13.6383 18.3161 13.6383Z"/>
</svg>
</div> </div>
{/* Voice Connection Panel */}
<div
className={`server-icon ${view === 'server' ? 'active' : ''}`}
onClick={() => onViewChange('server')}
style={{ cursor: 'pointer' }}
>Sc</div>
</div>
{view === 'me' ? renderDMView() : renderServerView()}
</div>
{connectionState === 'connected' && ( {connectionState === 'connected' && (
<div style={{ <div style={{
backgroundColor: '#292b2f', backgroundColor: '#292b2f',
@@ -672,32 +607,18 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</div> </div>
<div style={{ color: '#dcddde', fontSize: 12, marginBottom: 8 }}>{voiceChannelName} / Secure Chat</div> <div style={{ color: '#dcddde', fontSize: 12, marginBottom: 8 }}>{voiceChannelName} / Secure Chat</div>
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
<button <button onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)} title="Turn On Camera" style={voicePanelButtonStyle}>
onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)}
title="Turn On Camera"
style={{
flex: 1, alignItems: 'center', minHeight: '32px', padding: "calc(var(--space-8) - 1px) calc(var(--space-16) - 1px)", background: 'hsl(240 calc(1*4%) 60.784% /0.0784313725490196)', border: 'hsl(0 calc(1*0%) 100% /0.0784313725490196)', borderColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.0392156862745098) 100%,hsl(0 0% 0% /0.0392156862745098) 0%)', borderRadius: '8px', cursor: 'pointer', padding: '4px', display: 'flex', justifyContent: 'center'
}}
>
<ColoredIcon src={cameraIcon} color="#b9bbbe" size="20px" /> <ColoredIcon src={cameraIcon} color="#b9bbbe" size="20px" />
</button> </button>
<button <button onClick={handleScreenShareClick} title="Share Screen" style={voicePanelButtonStyle}>
onClick={handleScreenShareClick}
title="Share Screen"
style={{
flex: 1, alignItems: 'center', minHeight: '32px', padding: "calc(var(--space-8) - 1px) calc(var(--space-16) - 1px)", background: 'hsl(240 calc(1*4%) 60.784% /0.0784313725490196)', border: 'hsl(0 calc(1*0%) 100% /0.0784313725490196)', borderColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.0392156862745098) 100%,hsl(0 0% 0% /0.0392156862745098) 0%)', borderRadius: '8px', cursor: 'pointer', padding: '4px', display: 'flex', justifyContent: 'center'
}}
>
<ColoredIcon src={screenIcon} color="#b9bbbe" size="20px" /> <ColoredIcon src={screenIcon} color="#b9bbbe" size="20px" />
</button> </button>
</div> </div>
</div> </div>
)} )}
{/* User Control Panel at Bottom, Spanning Full Width */}
<UserControlPanel username={username} /> <UserControlPanel username={username} />
{/* Modals */}
{editingChannel && ( {editingChannel && (
<ChannelSettingsModal <ChannelSettingsModal
channel={editingChannel} channel={editingChannel}

View File

@@ -12,43 +12,51 @@ import unmuteSound from '../assets/sounds/unmute.mp3';
import deafenSound from '../assets/sounds/deafen.mp3'; import deafenSound from '../assets/sounds/deafen.mp3';
import undeafenSound from '../assets/sounds/undeafen.mp3'; import undeafenSound from '../assets/sounds/undeafen.mp3';
const soundMap = {
join: joinSound,
leave: leaveSound,
mute: muteSound,
unmute: unmuteSound,
deafen: deafenSound,
undeafen: undeafenSound
};
const VoiceContext = createContext(); const VoiceContext = createContext();
export const useVoice = () => useContext(VoiceContext); export const useVoice = () => useContext(VoiceContext);
function playSound(type) {
const src = soundMap[type];
if (!src) return;
const audio = new Audio(src);
audio.volume = 0.5;
audio.play().catch(e => console.error("Sound play failed", e));
}
export const VoiceProvider = ({ children }) => { export const VoiceProvider = ({ children }) => {
const [activeChannelId, setActiveChannelId] = useState(null); const [activeChannelId, setActiveChannelId] = useState(null);
const [activeChannelName, setActiveChannelName] = useState(null); const [activeChannelName, setActiveChannelName] = useState(null);
const [connectionState, setConnectionState] = useState('disconnected'); const [connectionState, setConnectionState] = useState('disconnected');
const [room, setRoom] = useState(null); const [room, setRoom] = useState(null);
const [token, setToken] = useState(null); const [token, setToken] = useState(null);
const [activeSpeakers, setActiveSpeakers] = useState(new Set());
const [activeSpeakers, setActiveSpeakers] = useState(new Set()); // Set<userId>
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [isDeafened, setIsDeafened] = useState(false); const [isDeafened, setIsDeafened] = useState(false);
const [isScreenSharing, setIsScreenSharingLocal] = useState(false);
const convex = useConvex(); const convex = useConvex();
// Reactive voice states from Convex (replaces socket.io)
const voiceStates = useQuery(api.voiceState.getAll) || {}; const voiceStates = useQuery(api.voiceState.getAll) || {};
// Sound Helper async function updateVoiceState(fields) {
const playSound = (type) => { const userId = localStorage.getItem('userId');
const sounds = { if (!userId || !activeChannelId) return;
join: joinSound, try {
leave: leaveSound, await convex.mutation(api.voiceState.updateState, { userId, ...fields });
mute: muteSound, } catch (e) {
unmute: unmuteSound, console.error('Failed to update voice state:', e);
deafen: deafenSound, }
undeafen: undeafenSound
};
const src = sounds[type];
if (src) {
const audio = new Audio(src);
audio.volume = 0.5;
audio.play().catch(e => console.error("Sound play failed", e));
} }
};
const connectToVoice = async (channelId, channelName, userId) => { const connectToVoice = async (channelId, channelName, userId) => {
if (activeChannelId === channelId) return; if (activeChannelId === channelId) return;
@@ -60,7 +68,6 @@ export const VoiceProvider = ({ children }) => {
setConnectionState('connecting'); setConnectionState('connecting');
try { try {
// Get LiveKit token via Convex action
const { token: lkToken } = await convex.action(api.voice.getToken, { const { token: lkToken } = await convex.action(api.voice.getToken, {
channelId, channelId,
userId, userId,
@@ -71,30 +78,24 @@ export const VoiceProvider = ({ children }) => {
setToken(lkToken); setToken(lkToken);
// Disable adaptiveStream to ensure all tracks are available/subscribed immediately
const newRoom = new Room({ adaptiveStream: false, dynacast: false, autoSubscribe: true }); const newRoom = new Room({ adaptiveStream: false, dynacast: false, autoSubscribe: true });
const liveKitUrl = import.meta.env.VITE_LIVEKIT_URL; await newRoom.connect(import.meta.env.VITE_LIVEKIT_URL, lkToken);
await newRoom.connect(liveKitUrl, lkToken);
// Auto-enable microphone & Apply Mute/Deafen State await newRoom.localParticipant.setMicrophoneEnabled(!isMuted && !isDeafened);
const shouldEnableMic = !isMuted && !isDeafened;
await newRoom.localParticipant.setMicrophoneEnabled(shouldEnableMic);
setRoom(newRoom); setRoom(newRoom);
setConnectionState('connected'); setConnectionState('connected');
window.voiceRoom = newRoom; // For debugging window.voiceRoom = newRoom;
playSound('join'); playSound('join');
// Update voice state in Convex
await convex.mutation(api.voiceState.join, { await convex.mutation(api.voiceState.join, {
channelId, channelId,
userId, userId,
username: localStorage.getItem('username') || 'Unknown', username: localStorage.getItem('username') || 'Unknown',
isMuted: isMuted, isMuted,
isDeafened: isDeafened, isDeafened,
}); });
// Events
newRoom.on(RoomEvent.Disconnected, async (reason) => { newRoom.on(RoomEvent.Disconnected, async (reason) => {
console.warn('Voice Room Disconnected. Reason:', reason); console.warn('Voice Room Disconnected. Reason:', reason);
playSound('leave'); playSound('leave');
@@ -104,7 +105,6 @@ export const VoiceProvider = ({ children }) => {
setToken(null); setToken(null);
setActiveSpeakers(new Set()); setActiveSpeakers(new Set());
// Remove voice state in Convex
try { try {
await convex.mutation(api.voiceState.leave, { userId }); await convex.mutation(api.voiceState.leave, { userId });
} catch (e) { } catch (e) {
@@ -113,9 +113,7 @@ export const VoiceProvider = ({ children }) => {
}); });
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => { newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
const newActive = new Set(); setActiveSpeakers(new Set(speakers.map(p => p.identity)));
speakers.forEach(p => newActive.add(p.identity));
setActiveSpeakers(newActive);
}); });
} catch (err) { } catch (err) {
@@ -137,63 +135,22 @@ export const VoiceProvider = ({ children }) => {
if (room) { if (room) {
room.localParticipant.setMicrophoneEnabled(!nextState); room.localParticipant.setMicrophoneEnabled(!nextState);
} }
await updateVoiceState({ isMuted: nextState });
const userId = localStorage.getItem('userId');
if (userId && activeChannelId) {
try {
await convex.mutation(api.voiceState.updateState, {
userId,
isMuted: nextState,
});
} catch (e) {
console.error('Failed to update mute state:', e);
}
}
}; };
const toggleDeafen = async () => { const toggleDeafen = async () => {
const nextState = !isDeafened; const nextState = !isDeafened;
setIsDeafened(nextState); setIsDeafened(nextState);
playSound(nextState ? 'deafen' : 'undeafen'); playSound(nextState ? 'deafen' : 'undeafen');
if (nextState) {
if (room && !isMuted) { if (room && !isMuted) {
room.localParticipant.setMicrophoneEnabled(false); room.localParticipant.setMicrophoneEnabled(!nextState);
}
} else {
if (room && !isMuted) {
room.localParticipant.setMicrophoneEnabled(true);
}
}
const userId = localStorage.getItem('userId');
if (userId && activeChannelId) {
try {
await convex.mutation(api.voiceState.updateState, {
userId,
isDeafened: nextState,
});
} catch (e) {
console.error('Failed to update deafen state:', e);
}
} }
await updateVoiceState({ isDeafened: nextState });
}; };
const [isScreenSharing, setIsScreenSharingLocal] = useState(false);
const setScreenSharing = async (active) => { const setScreenSharing = async (active) => {
setIsScreenSharingLocal(active); setIsScreenSharingLocal(active);
await updateVoiceState({ isScreenSharing: active });
const userId = localStorage.getItem('userId');
if (userId && activeChannelId) {
try {
await convex.mutation(api.voiceState.updateState, {
userId,
isScreenSharing: active,
});
} catch (e) {
console.error('Failed to update screen sharing state:', e);
}
}
}; };
return ( return (
@@ -220,7 +177,6 @@ export const VoiceProvider = ({ children }) => {
room={room} room={room}
style={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden' }} style={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden' }}
> >
{/* Mute audio renderer if deafened */}
<RoomAudioRenderer muted={isDeafened} /> <RoomAudioRenderer muted={isDeafened} />
</LiveKitRoom> </LiveKitRoom>
)} )}

View File

@@ -273,6 +273,28 @@ body {
background-color: rgba(2, 2, 2, 0.06); background-color: rgba(2, 2, 2, 0.06);
} }
.message-grouped {
margin-top: 2px;
}
.grouped-timestamp-wrapper {
display: flex;
align-items: center;
justify-content: center;
margin-top: 0;
}
.grouped-timestamp {
display: none;
font-size: 0.65rem;
color: #72767d;
white-space: nowrap;
}
.message-grouped:hover .grouped-timestamp {
display: block;
}
.message-avatar-wrapper { .message-avatar-wrapper {
width: 40px; width: 40px;
margin-right: 16px; margin-right: 16px;
@@ -631,6 +653,54 @@ body {
cursor: help; cursor: help;
} }
/* Loading spinner */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.loading-spinner {
width: 32px;
height: 32px;
border-radius: 50%;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: #5865f2;
animation: spin 0.8s linear infinite;
}
/* Channel beginning indicator */
.channel-beginning {
padding: 16px 16px 8px;
margin-bottom: 8px;
}
.channel-beginning-icon {
width: 68px;
height: 68px;
border-radius: 50%;
background-color: #41434a;
display: flex;
align-items: center;
justify-content: center;
font-size: 36px;
font-weight: 600;
color: #fff;
margin-bottom: 8px;
}
.channel-beginning-title {
font-size: 32px;
font-weight: 700;
color: #fff;
margin: 8px 0 4px;
}
.channel-beginning-subtitle {
font-size: 15px;
color: #949ba4;
margin: 0;
}
/* Utility to hide scrollbar but allow scrolling */ /* Utility to hide scrollbar but allow scrolling */
.no-scrollbar { .no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none; /* IE and Edge */

View File

@@ -8,33 +8,27 @@ import { useVoice } from '../contexts/VoiceContext';
import FriendsView from '../components/FriendsView'; import FriendsView from '../components/FriendsView';
const Chat = () => { const Chat = () => {
const [view, setView] = useState('server'); // 'server' | 'me' const [view, setView] = useState('server');
const [activeChannel, setActiveChannel] = useState(null); const [activeChannel, setActiveChannel] = useState(null);
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [userId, setUserId] = useState(null); const [userId, setUserId] = useState(null);
const [channelKeys, setChannelKeys] = useState({}); // { channelId: key_hex } const [channelKeys, setChannelKeys] = useState({});
const [activeDMChannel, setActiveDMChannel] = useState(null);
// DM state
const [activeDMChannel, setActiveDMChannel] = useState(null); // { channel_id, other_username }
const convex = useConvex(); const convex = useConvex();
// Reactive channel list from Convex (auto-updates!)
const channels = useQuery(api.channels.list) || []; const channels = useQuery(api.channels.list) || [];
// Reactive channel keys from Convex
const rawChannelKeys = useQuery( const rawChannelKeys = useQuery(
api.channelKeys.getKeysForUser, api.channelKeys.getKeysForUser,
userId ? { userId } : "skip" userId ? { userId } : "skip"
); );
// Reactive DM channels from Convex
const dmChannels = useQuery( const dmChannels = useQuery(
api.dms.listDMs, api.dms.listDMs,
userId ? { userId } : "skip" userId ? { userId } : "skip"
) || []; ) || [];
// Initialize user from localStorage
useEffect(() => { useEffect(() => {
const storedUsername = localStorage.getItem('username'); const storedUsername = localStorage.getItem('username');
const storedUserId = localStorage.getItem('userId'); const storedUserId = localStorage.getItem('userId');
@@ -42,58 +36,50 @@ const Chat = () => {
if (storedUserId) setUserId(storedUserId); if (storedUserId) setUserId(storedUserId);
}, []); }, []);
// Decrypt channel keys when raw keys change
useEffect(() => { useEffect(() => {
if (!rawChannelKeys || rawChannelKeys.length === 0) return; if (!rawChannelKeys || rawChannelKeys.length === 0) return;
const privateKey = sessionStorage.getItem('privateKey'); const privateKey = sessionStorage.getItem('privateKey');
if (!privateKey) return; if (!privateKey) return;
const decryptKeys = async () => { async function decryptKeys() {
const keys = {}; const keys = {};
for (const item of rawChannelKeys) { for (const item of rawChannelKeys) {
try { try {
const bundleJson = await window.cryptoAPI.privateDecrypt(privateKey, item.encrypted_key_bundle); const bundleJson = await window.cryptoAPI.privateDecrypt(privateKey, item.encrypted_key_bundle);
const bundle = JSON.parse(bundleJson); Object.assign(keys, JSON.parse(bundleJson));
Object.assign(keys, bundle);
} catch (e) { } catch (e) {
console.error(`Failed to decrypt keys for channel ${item.channel_id}`, e); console.error(`Failed to decrypt keys for channel ${item.channel_id}`, e);
} }
} }
setChannelKeys(keys); setChannelKeys(keys);
}; }
decryptKeys(); decryptKeys();
}, [rawChannelKeys]); }, [rawChannelKeys]);
// Auto-select first text channel when channels load
useEffect(() => { useEffect(() => {
if (!activeChannel && channels.length > 0) { if (activeChannel || channels.length === 0) return;
const firstTextChannel = channels.find(c => c.type === 'text'); const firstTextChannel = channels.find(c => c.type === 'text');
if (firstTextChannel) { if (firstTextChannel) {
setActiveChannel(firstTextChannel._id); setActiveChannel(firstTextChannel._id);
} }
}
}, [channels, activeChannel]); }, [channels, activeChannel]);
const openDM = useCallback(async (targetUserId, targetUsername) => { const openDM = useCallback(async (targetUserId, targetUsername) => {
const uid = localStorage.getItem('userId'); const uid = localStorage.getItem('userId');
const privateKey = sessionStorage.getItem('privateKey');
if (!uid) return; if (!uid) return;
try { try {
// 1. Find or create the DM channel
const { channelId, created } = await convex.mutation(api.dms.openDM, { const { channelId, created } = await convex.mutation(api.dms.openDM, {
userId: uid, userId: uid,
targetUserId targetUserId
}); });
// 2. If newly created, generate + distribute an AES key for both users
if (created) { if (created) {
const keyBytes = new Uint8Array(32); const keyBytes = new Uint8Array(32);
crypto.getRandomValues(keyBytes); crypto.getRandomValues(keyBytes);
const keyHex = Array.from(keyBytes).map(b => b.toString(16).padStart(2, '0')).join(''); const keyHex = Array.from(keyBytes).map(b => b.toString(16).padStart(2, '0')).join('');
// Fetch both users' public keys
const allUsers = await convex.query(api.auth.getPublicKeys, {}); const allUsers = await convex.query(api.auth.getPublicKeys, {});
const participants = allUsers.filter(u => u.id === uid || u.id === targetUserId); const participants = allUsers.filter(u => u.id === uid || u.id === targetUserId);
@@ -117,10 +103,8 @@ const Chat = () => {
if (batchKeys.length > 0) { if (batchKeys.length > 0) {
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys }); await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
} }
// Channel keys will auto-update via reactive query
} }
// 3. Set active DM and switch to me view
setActiveDMChannel({ channel_id: channelId, other_username: targetUsername }); setActiveDMChannel({ channel_id: channelId, other_username: targetUsername });
setView('me'); setView('me');
@@ -129,13 +113,10 @@ const Chat = () => {
} }
}, [convex]); }, [convex]);
// Helper to get active channel object
const activeChannelObj = channels.find(c => c._id === activeChannel); const activeChannelObj = channels.find(c => c._id === activeChannel);
const { room, voiceStates } = useVoice(); const { room, voiceStates } = useVoice();
// Determine what to render in the main area function renderMainContent() {
const renderMainContent = () => {
if (view === 'me') { if (view === 'me') {
if (activeDMChannel) { if (activeDMChannel) {
return ( return (
@@ -173,7 +154,7 @@ const Chat = () => {
<p>Click the <b>+</b> in the sidebar to create your first encrypted channel.</p> <p>Click the <b>+</b> in the sidebar to create your first encrypted channel.</p>
</div> </div>
); );
}; }
return ( return (
<div className="app-container"> <div className="app-container">
@@ -184,9 +165,7 @@ const Chat = () => {
username={username} username={username}
channelKeys={channelKeys} channelKeys={channelKeys}
view={view} view={view}
onViewChange={(v) => { onViewChange={setView}
setView(v);
}}
onOpenDM={openDM} onOpenDM={openDM}
activeDMChannel={activeDMChannel} activeDMChannel={activeDMChannel}
setActiveDMChannel={setActiveDMChannel} setActiveDMChannel={setActiveDMChannel}

View File

@@ -3,6 +3,11 @@ import { Link, useNavigate } from 'react-router-dom';
import { useConvex } from 'convex/react'; import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api'; import { api } from '../../../../convex/_generated/api';
async function decryptEncryptedField(encryptedJson, keyHex) {
const obj = JSON.parse(encryptedJson);
return window.cryptoAPI.decryptData(obj.content, keyHex, obj.iv, obj.tag);
}
const Login = () => { const Login = () => {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -19,15 +24,12 @@ const Login = () => {
try { try {
console.log('Starting login for:', username); console.log('Starting login for:', username);
// 1. Get Salt (via Convex query)
const { salt } = await convex.query(api.auth.getSalt, { username }); const { salt } = await convex.query(api.auth.getSalt, { username });
console.log('Got salt'); console.log('Got salt');
// 2. Derive Keys (DEK, DAK)
const { dek, dak } = await window.cryptoAPI.deriveAuthKeys(password, salt); const { dek, dak } = await window.cryptoAPI.deriveAuthKeys(password, salt);
console.log('Derived keys'); console.log('Derived keys');
// 3. Verify with Convex
const verifyData = await convex.mutation(api.auth.verifyUser, { username, dak }); const verifyData = await convex.mutation(api.auth.verifyUser, { username, dak });
if (verifyData.error) { if (verifyData.error) {
@@ -43,39 +45,15 @@ const Login = () => {
console.error('MISSING USERID IN VERIFY RESPONSE!', verifyData); console.error('MISSING USERID IN VERIFY RESPONSE!', verifyData);
} }
// 4. Decrypt Master Key (using DEK)
console.log('Decrypting Master Key...'); console.log('Decrypting Master Key...');
const encryptedMKObj = JSON.parse(verifyData.encryptedMK); const mkHex = await decryptEncryptedField(verifyData.encryptedMK, dek);
const mkHex = await window.cryptoAPI.decryptData(
encryptedMKObj.content,
dek,
encryptedMKObj.iv,
encryptedMKObj.tag
);
// 5. Decrypt Private Keys (using MK)
console.log('Decrypting Private Keys...'); console.log('Decrypting Private Keys...');
const encryptedPrivateKeysObj = JSON.parse(verifyData.encryptedPrivateKeys); const encryptedPrivateKeysObj = JSON.parse(verifyData.encryptedPrivateKeys);
// Decrypt Ed25519 Signing Key const signingKey = await decryptEncryptedField(JSON.stringify(encryptedPrivateKeysObj.ed), mkHex);
const edPrivObj = encryptedPrivateKeysObj.ed; const rsaPriv = await decryptEncryptedField(JSON.stringify(encryptedPrivateKeysObj.rsa), mkHex);
const signingKey = await window.cryptoAPI.decryptData(
edPrivObj.content,
mkHex,
edPrivObj.iv,
edPrivObj.tag
);
// Decrypt RSA Private Key (Identity Key)
const rsaPrivObj = encryptedPrivateKeysObj.rsa;
const rsaPriv = await window.cryptoAPI.decryptData(
rsaPrivObj.content,
mkHex,
rsaPrivObj.iv,
rsaPrivObj.tag
);
// Store Keys in Session (Memory-like) storage
sessionStorage.setItem('signingKey', signingKey); sessionStorage.setItem('signingKey', signingKey);
sessionStorage.setItem('privateKey', rsaPriv); sessionStorage.setItem('privateKey', rsaPriv);
console.log('Keys decrypted and stored in session.'); console.log('Keys decrypted and stored in session.');
@@ -85,7 +63,6 @@ const Login = () => {
localStorage.setItem('publicKey', verifyData.publicKey); localStorage.setItem('publicKey', verifyData.publicKey);
} }
// Verify immediate read back
console.log('Immediate localStorage read check:', localStorage.getItem('userId')); console.log('Immediate localStorage read check:', localStorage.getItem('userId'));
navigate('/chat'); navigate('/chat');

View File

@@ -3,12 +3,19 @@ import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useConvex } from 'convex/react'; import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api'; import { api } from '../../../../convex/_generated/api';
function parseInviteParams(input) {
const codeMatch = input.match(/[?&]code=([^&]+)/);
const keyMatch = input.match(/[?&]key=([^&]+)/);
if (codeMatch && keyMatch) return { code: codeMatch[1], secret: keyMatch[1] };
return null;
}
const Register = () => { const Register = () => {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [inviteKeys, setInviteKeys] = useState(null); // { channelId: keyHex } const [inviteKeys, setInviteKeys] = useState(null);
const [inviteLinkInput, setInviteLinkInput] = useState(''); const [inviteLinkInput, setInviteLinkInput] = useState('');
const [activeInviteCode, setActiveInviteCode] = useState(null); const [activeInviteCode, setActiveInviteCode] = useState(null);
@@ -16,7 +23,6 @@ const Register = () => {
const location = useLocation(); const location = useLocation();
const convex = useConvex(); const convex = useConvex();
// Helper to process code/key
const processInvite = async (code, secret) => { const processInvite = async (code, secret) => {
if (!window.cryptoAPI) { if (!window.cryptoAPI) {
setError("Critical Error: Secure Crypto API missing. Run in Electron."); setError("Critical Error: Secure Crypto API missing. Run in Electron.");
@@ -24,16 +30,13 @@ const Register = () => {
} }
try { try {
// Fetch Invite via Convex
const result = await convex.query(api.invites.use, { code }); const result = await convex.query(api.invites.use, { code });
if (result.error) throw new Error(result.error); if (result.error) throw new Error(result.error);
const { encryptedPayload } = result;
// Decrypt Payload const blob = JSON.parse(result.encryptedPayload);
const blob = JSON.parse(encryptedPayload);
const decrypted = await window.cryptoAPI.decryptData(blob.c, secret, blob.iv, blob.t); const decrypted = await window.cryptoAPI.decryptData(blob.c, secret, blob.iv, blob.t);
const keys = JSON.parse(decrypted); const keys = JSON.parse(decrypted);
console.log('Invite keys decrypted successfully:', Object.keys(keys).length); console.log('Invite keys decrypted successfully:', Object.keys(keys).length);
setInviteKeys(keys); setInviteKeys(keys);
setActiveInviteCode(code); setActiveInviteCode(code);
@@ -44,7 +47,6 @@ const Register = () => {
} }
}; };
// Handle Invite Link parsing from URL
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const code = params.get('code'); const code = params.get('code');
@@ -57,18 +59,12 @@ const Register = () => {
}, [location]); }, [location]);
const handleManualInvite = () => { const handleManualInvite = () => {
try { const parsed = parseInviteParams(inviteLinkInput);
const codeMatch = inviteLinkInput.match(/[?&]code=([^&]+)/); if (parsed) {
const keyMatch = inviteLinkInput.match(/[?&]key=([^&]+)/); processInvite(parsed.code, parsed.secret);
if (codeMatch && keyMatch) {
processInvite(codeMatch[1], keyMatch[1]);
} else { } else {
setError("Invalid invite link format."); setError("Invalid invite link format.");
} }
} catch (e) {
setError("Invalid URL.");
}
}; };
const handleRegister = async (e) => { const handleRegister = async (e) => {
@@ -79,36 +75,18 @@ const Register = () => {
try { try {
console.log('Starting registration for:', username); console.log('Starting registration for:', username);
// 1. Generate Salt and Master Key (MK)
const salt = await window.cryptoAPI.randomBytes(16); const salt = await window.cryptoAPI.randomBytes(16);
const mk = await window.cryptoAPI.randomBytes(32); const mk = await window.cryptoAPI.randomBytes(32);
console.log('Generated Salt and MK');
// 2. Derive Keys (DEK, DAK)
const { dek, dak } = await window.cryptoAPI.deriveAuthKeys(password, salt); const { dek, dak } = await window.cryptoAPI.deriveAuthKeys(password, salt);
console.log('Derived keys'); const encryptedMK = JSON.stringify(await window.cryptoAPI.encryptData(mk, dek));
// 3. Encrypt MK with DEK
const encryptedMKObj = await window.cryptoAPI.encryptData(mk, dek);
const encryptedMK = JSON.stringify(encryptedMKObj);
// 4. Hash DAK for Auth Proof
const hak = await window.cryptoAPI.sha256(dak); const hak = await window.cryptoAPI.sha256(dak);
// 5. Generate Key Pairs
const keys = await window.cryptoAPI.generateKeys(); const keys = await window.cryptoAPI.generateKeys();
// 6. Encrypt Private Keys with MK
const encryptedRsaPriv = await window.cryptoAPI.encryptData(keys.rsaPriv, mk);
const encryptedEdPriv = await window.cryptoAPI.encryptData(keys.edPriv, mk);
const encryptedPrivateKeys = JSON.stringify({ const encryptedPrivateKeys = JSON.stringify({
rsa: encryptedRsaPriv, rsa: await window.cryptoAPI.encryptData(keys.rsaPriv, mk),
ed: encryptedEdPriv ed: await window.cryptoAPI.encryptData(keys.edPriv, mk)
}); });
// 7. Register via Convex
const data = await convex.mutation(api.auth.createUserWithProfile, { const data = await convex.mutation(api.auth.createUserWithProfile, {
username, username,
salt, salt,
@@ -120,34 +98,25 @@ const Register = () => {
inviteCode: activeInviteCode || undefined inviteCode: activeInviteCode || undefined
}); });
if (data.error) { if (data.error) throw new Error(data.error);
throw new Error(data.error);
}
console.log('Registration successful:', data); console.log('Registration successful:', data);
// 8. Upload Invite Keys (If present)
if (inviteKeys && data.userId) { if (inviteKeys && data.userId) {
console.log('Uploading invite keys...'); console.log('Uploading invite keys...');
const batchKeys = []; const batchKeys = await Promise.all(
for (const [channelId, channelKeyHex] of Object.entries(inviteKeys)) { Object.entries(inviteKeys).map(async ([channelId, channelKeyHex]) => {
try {
const payload = JSON.stringify({ [channelId]: channelKeyHex }); const payload = JSON.stringify({ [channelId]: channelKeyHex });
const encryptedKeyBundle = await window.cryptoAPI.publicEncrypt(keys.rsaPub, payload); const encryptedKeyBundle = await window.cryptoAPI.publicEncrypt(keys.rsaPub, payload);
return { channelId, userId: data.userId, encryptedKeyBundle, keyVersion: 1 };
}).map(p => p.catch(err => {
console.error('Failed to encrypt key for channel:', err);
return null;
}))
);
batchKeys.push({ const validKeys = batchKeys.filter(Boolean);
channelId, if (validKeys.length > 0) {
userId: data.userId, await convex.mutation(api.channelKeys.uploadKeys, { keys: validKeys });
encryptedKeyBundle,
keyVersion: 1
});
} catch (keyErr) {
console.error('Failed to encrypt key for channel:', channelId, keyErr);
}
}
if (batchKeys.length > 0) {
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
console.log('Uploaded invite keys'); console.log('Uploaded invite keys');
} }
} }
@@ -170,10 +139,8 @@ const Register = () => {
</div> </div>
{error && <div style={{ color: 'red', marginBottom: 10, textAlign: 'center' }}>{error}</div>} {error && <div style={{ color: 'red', marginBottom: 10, textAlign: 'center' }}>{error}</div>}
{/* Manual Invite Input - Fallback for Desktop App */}
{!inviteKeys && ( {!inviteKeys && (
<div style={{ marginBottom: '15px' }}> <div style={{ marginBottom: '15px', display: 'flex' }}>
<div style={{ display: 'flex' }}>
<input <input
type="text" type="text"
placeholder="Paste Invite Link Here..." placeholder="Paste Invite Link Here..."
@@ -185,9 +152,6 @@ const Register = () => {
Apply Apply
</button> </button>
</div> </div>
</div>
)} )}
{inviteKeys ? ( {inviteKeys ? (

View File

@@ -1,6 +1,16 @@
import { query, mutation } from "./_generated/server"; import { query, mutation } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
async function sha256Hex(input: string): Promise<string> {
const buffer = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(input)
);
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
// Get salt for a username (returns fake salt for non-existent users) // Get salt for a username (returns fake salt for non-existent users)
export const getSalt = query({ export const getSalt = query({
args: { username: v.string() }, args: { username: v.string() },
@@ -16,15 +26,7 @@ export const getSalt = query({
} }
// Generate deterministic fake salt for non-existent users (privacy) // Generate deterministic fake salt for non-existent users (privacy)
// Simple HMAC-like approach using username const fakeSalt = await sha256Hex("SERVER_SECRET_KEY" + args.username);
const encoder = new TextEncoder();
const data = encoder.encode("SERVER_SECRET_KEY" + args.username);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = new Uint8Array(hashBuffer);
const fakeSalt = Array.from(hashArray)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return { salt: fakeSalt }; return { salt: fakeSalt };
}, },
}); });
@@ -55,14 +57,7 @@ export const verifyUser = mutation({
return { error: "Invalid credentials" }; return { error: "Invalid credentials" };
} }
// Hash the DAK with SHA-256 and compare const hashedDAK = await sha256Hex(args.dak);
const encoder = new TextEncoder();
const dakBuffer = encoder.encode(args.dak);
const hashBuffer = await crypto.subtle.digest("SHA-256", dakBuffer);
const hashArray = new Uint8Array(hashBuffer);
const hashedDAK = Array.from(hashArray)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
if (hashedDAK === user.hashedAuthKey) { if (hashedDAK === user.hashedAuthKey) {
return { return {
@@ -95,7 +90,6 @@ export const createUserWithProfile = mutation({
v.object({ error: v.string() }) v.object({ error: v.string() })
), ),
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Check if username is taken
const existing = await ctx.db const existing = await ctx.db
.query("userProfiles") .query("userProfiles")
.withIndex("by_username", (q) => q.eq("username", args.username)) .withIndex("by_username", (q) => q.eq("username", args.username))
@@ -105,17 +99,14 @@ export const createUserWithProfile = mutation({
return { error: "Username taken" }; return { error: "Username taken" };
} }
// Count existing users const isFirstUser =
const allUsers = await ctx.db.query("userProfiles").collect(); (await ctx.db.query("userProfiles").first()) === null;
const userCount = allUsers.length;
// Enforce invite code for non-first users if (!isFirstUser) {
if (userCount > 0) {
if (!args.inviteCode) { if (!args.inviteCode) {
return { error: "Invite code required" }; return { error: "Invite code required" };
} }
// Validate invite
const invite = await ctx.db const invite = await ctx.db
.query("invites") .query("invites")
.withIndex("by_code", (q) => q.eq("code", args.inviteCode)) .withIndex("by_code", (q) => q.eq("code", args.inviteCode))
@@ -137,11 +128,9 @@ export const createUserWithProfile = mutation({
return { error: "Invite max uses reached" }; return { error: "Invite max uses reached" };
} }
// Increment invite usage
await ctx.db.patch(invite._id, { uses: invite.uses + 1 }); await ctx.db.patch(invite._id, { uses: invite.uses + 1 });
} }
// Create user profile
const userId = await ctx.db.insert("userProfiles", { const userId = await ctx.db.insert("userProfiles", {
username: args.username, username: args.username,
clientSalt: args.salt, clientSalt: args.salt,
@@ -150,12 +139,10 @@ export const createUserWithProfile = mutation({
publicIdentityKey: args.publicKey, publicIdentityKey: args.publicKey,
publicSigningKey: args.signingKey, publicSigningKey: args.signingKey,
encryptedPrivateKeys: args.encryptedPrivateKeys, encryptedPrivateKeys: args.encryptedPrivateKeys,
isAdmin: userCount === 0, isAdmin: isFirstUser,
}); });
// First user bootstrap: create Owner + @everyone roles if they don't exist if (isFirstUser) {
if (userCount === 0) {
// Create @everyone role
const everyoneRoleId = await ctx.db.insert("roles", { const everyoneRoleId = await ctx.db.insert("roles", {
name: "@everyone", name: "@everyone",
color: "#99aab5", color: "#99aab5",
@@ -168,7 +155,6 @@ export const createUserWithProfile = mutation({
isHoist: false, isHoist: false,
}); });
// Create Owner role
const ownerRoleId = await ctx.db.insert("roles", { const ownerRoleId = await ctx.db.insert("roles", {
name: "Owner", name: "Owner",
color: "#e91e63", color: "#e91e63",
@@ -183,11 +169,9 @@ export const createUserWithProfile = mutation({
isHoist: true, isHoist: true,
}); });
// Assign both roles to first user
await ctx.db.insert("userRoles", { userId, roleId: everyoneRoleId }); await ctx.db.insert("userRoles", { userId, roleId: everyoneRoleId });
await ctx.db.insert("userRoles", { userId, roleId: ownerRoleId }); await ctx.db.insert("userRoles", { userId, roleId: ownerRoleId });
} else { } else {
// Assign @everyone role to new user
const everyoneRole = await ctx.db const everyoneRole = await ctx.db
.query("roles") .query("roles")
.filter((q) => q.eq(q.field("name"), "@everyone")) .filter((q) => q.eq(q.field("name"), "@everyone"))

View File

@@ -1,5 +1,26 @@
import { query, mutation } from "./_generated/server"; import { query, mutation } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
import { GenericMutationCtx } from "convex/server";
import { DataModel, Id } from "./_generated/dataModel";
type TableWithChannelIndex =
| "channelKeys"
| "dmParticipants"
| "typingIndicators"
| "voiceStates";
async function deleteByChannel(
ctx: GenericMutationCtx<DataModel>,
table: TableWithChannelIndex,
channelId: Id<"channels">
) {
const docs = await (ctx.db.query(table) as any)
.withIndex("by_channel", (q: any) => q.eq("channelId", channelId))
.collect();
for (const doc of docs) {
await ctx.db.delete(doc._id);
}
}
// List all non-DM channels // List all non-DM channels
export const list = query({ export const list = query({
@@ -49,7 +70,6 @@ export const create = mutation({
throw new Error("Channel name required"); throw new Error("Channel name required");
} }
// Check for duplicate name
const existing = await ctx.db const existing = await ctx.db
.query("channels") .query("channels")
.withIndex("by_name", (q) => q.eq("name", args.name)) .withIndex("by_name", (q) => q.eq("name", args.name))
@@ -105,13 +125,12 @@ export const remove = mutation({
throw new Error("Channel not found"); throw new Error("Channel not found");
} }
// Delete messages // Delete reactions for all messages in this channel
const messages = await ctx.db const messages = await ctx.db
.query("messages") .query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.id)) .withIndex("by_channel", (q) => q.eq("channelId", args.id))
.collect(); .collect();
for (const msg of messages) { for (const msg of messages) {
// Delete reactions for this message
const reactions = await ctx.db const reactions = 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))
@@ -122,43 +141,11 @@ export const remove = mutation({
await ctx.db.delete(msg._id); await ctx.db.delete(msg._id);
} }
// Delete channel keys await deleteByChannel(ctx, "channelKeys", args.id);
const keys = await ctx.db await deleteByChannel(ctx, "dmParticipants", args.id);
.query("channelKeys") await deleteByChannel(ctx, "typingIndicators", args.id);
.withIndex("by_channel", (q) => q.eq("channelId", args.id)) await deleteByChannel(ctx, "voiceStates", args.id);
.collect();
for (const key of keys) {
await ctx.db.delete(key._id);
}
// Delete DM participants
const dmParts = await ctx.db
.query("dmParticipants")
.withIndex("by_channel", (q) => q.eq("channelId", args.id))
.collect();
for (const dp of dmParts) {
await ctx.db.delete(dp._id);
}
// Delete typing indicators
const typing = await ctx.db
.query("typingIndicators")
.withIndex("by_channel", (q) => q.eq("channelId", args.id))
.collect();
for (const t of typing) {
await ctx.db.delete(t._id);
}
// Delete voice states
const voiceStates = await ctx.db
.query("voiceStates")
.withIndex("by_channel", (q) => q.eq("channelId", args.id))
.collect();
for (const vs of voiceStates) {
await ctx.db.delete(vs._id);
}
// Delete channel itself
await ctx.db.delete(args.id); await ctx.db.delete(args.id);
return { success: true }; return { success: true };

View File

@@ -1,7 +1,6 @@
import { query, mutation } from "./_generated/server"; import { query, mutation } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
// Find-or-create DM channel between two users
export const openDM = mutation({ export const openDM = mutation({
args: { args: {
userId: v.id("userProfiles"), userId: v.id("userProfiles"),
@@ -16,11 +15,9 @@ export const openDM = mutation({
throw new Error("Cannot DM yourself"); throw new Error("Cannot DM yourself");
} }
// Deterministic channel name
const sorted = [args.userId, args.targetUserId].sort(); const sorted = [args.userId, args.targetUserId].sort();
const dmName = `dm-${sorted[0]}-${sorted[1]}`; const dmName = `dm-${sorted[0]}-${sorted[1]}`;
// Check if already exists
const existing = await ctx.db const existing = await ctx.db
.query("channels") .query("channels")
.withIndex("by_name", (q) => q.eq("name", dmName)) .withIndex("by_name", (q) => q.eq("name", dmName))
@@ -30,27 +27,20 @@ export const openDM = mutation({
return { channelId: existing._id, created: false }; return { channelId: existing._id, created: false };
} }
// Create DM channel
const channelId = await ctx.db.insert("channels", { const channelId = await ctx.db.insert("channels", {
name: dmName, name: dmName,
type: "dm", type: "dm",
}); });
// Add participants await Promise.all([
await ctx.db.insert("dmParticipants", { ctx.db.insert("dmParticipants", { channelId, userId: args.userId }),
channelId, ctx.db.insert("dmParticipants", { channelId, userId: args.targetUserId }),
userId: args.userId, ]);
});
await ctx.db.insert("dmParticipants", {
channelId,
userId: args.targetUserId,
});
return { channelId, created: true }; return { channelId, created: true };
}, },
}); });
// List user's DM channels with other user info
export const listDMs = query({ export const listDMs = query({
args: { userId: v.id("userProfiles") }, args: { userId: v.id("userProfiles") },
returns: v.array( returns: v.array(
@@ -62,43 +52,36 @@ export const listDMs = query({
}) })
), ),
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Get all DM participations for this user
const myParticipations = await ctx.db const myParticipations = await ctx.db
.query("dmParticipants") .query("dmParticipants")
.withIndex("by_user", (q) => q.eq("userId", args.userId)) .withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect(); .collect();
const result: Array<{ const results = await Promise.all(
channel_id: typeof myParticipations[0]["channelId"]; myParticipations.map(async (part) => {
channel_name: string;
other_user_id: string;
other_username: string;
}> = [];
for (const part of myParticipations) {
const channel = await ctx.db.get(part.channelId); const channel = await ctx.db.get(part.channelId);
if (!channel || channel.type !== "dm") continue; if (!channel || channel.type !== "dm") return null;
// Find other participant
const otherParts = await ctx.db const otherParts = await ctx.db
.query("dmParticipants") .query("dmParticipants")
.withIndex("by_channel", (q) => q.eq("channelId", part.channelId)) .withIndex("by_channel", (q) => q.eq("channelId", part.channelId))
.collect(); .collect();
const otherPart = otherParts.find((p) => p.userId !== args.userId); const otherPart = otherParts.find((p) => p.userId !== args.userId);
if (!otherPart) continue; if (!otherPart) return null;
const otherUser = await ctx.db.get(otherPart.userId); const otherUser = await ctx.db.get(otherPart.userId);
if (!otherUser) continue; if (!otherUser) return null;
result.push({ return {
channel_id: part.channelId, channel_id: part.channelId,
channel_name: channel.name, channel_name: channel.name,
other_user_id: otherUser._id, other_user_id: otherUser._id as string,
other_username: otherUser.username, other_username: otherUser.username,
}); };
} })
);
return result; return results.filter((r): r is NonNullable<typeof r> => r !== null);
}, },
}); });

View File

@@ -1,47 +1,36 @@
import { query, mutation } from "./_generated/server"; import { query, mutation } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
import { v } from "convex/values"; import { v } from "convex/values";
// List recent messages for a channel with reactions + username
export const list = query({ export const list = query({
args: { args: {
paginationOpts: paginationOptsValidator,
channelId: v.id("channels"), channelId: v.id("channels"),
userId: v.optional(v.id("userProfiles")), userId: v.optional(v.id("userProfiles")),
}, },
returns: v.array(v.any()), returns: v.any(),
handler: async (ctx, args) => { handler: async (ctx, args) => {
const messages = await ctx.db const result = await ctx.db
.query("messages") .query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId)) .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc") .order("desc")
.take(50); .paginate(args.paginationOpts);
// Reverse to get chronological order const enrichedPage = await Promise.all(
const chronological = messages.reverse(); result.page.map(async (msg) => {
// Enrich with username, signing key, and reactions
const enriched = await Promise.all(
chronological.map(async (msg) => {
// Get sender info
const sender = await ctx.db.get(msg.senderId); const sender = await ctx.db.get(msg.senderId);
// Get reactions for this message
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))
.collect(); .collect();
// Aggregate reactions const reactions: Record<string, { count: number; me: boolean }> = {};
const reactions: Record<
string,
{ count: number; me: boolean }
> = {};
for (const r of reactionDocs) { for (const r of reactionDocs) {
if (!reactions[r.emoji]) { const entry = (reactions[r.emoji] ??= { count: 0, me: false });
reactions[r.emoji] = { count: 0, me: false }; entry.count++;
}
reactions[r.emoji].count++;
if (args.userId && r.userId === args.userId) { if (args.userId && r.userId === args.userId) {
reactions[r.emoji].me = true; entry.me = true;
} }
} }
@@ -56,17 +45,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 || "",
reactions: reactions: Object.keys(reactions).length > 0 ? reactions : null,
Object.keys(reactions).length > 0 ? reactions : null,
}; };
}) })
); );
return enriched; return { ...result, page: enrichedPage };
}, },
}); });
// Send encrypted message
export const send = mutation({ export const send = mutation({
args: { args: {
channelId: v.id("channels"), channelId: v.id("channels"),
@@ -86,17 +73,14 @@ export const send = mutation({
signature: args.signature, signature: args.signature,
keyVersion: args.keyVersion, keyVersion: args.keyVersion,
}); });
return { id }; return { id };
}, },
}); });
// Delete a message
export const remove = mutation({ export const remove = mutation({
args: { id: v.id("messages") }, args: { id: v.id("messages") },
returns: v.null(), returns: v.null(),
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Delete reactions first
const reactions = await ctx.db const reactions = await ctx.db
.query("messageReactions") .query("messageReactions")
.withIndex("by_message", (q) => q.eq("messageId", args.id)) .withIndex("by_message", (q) => q.eq("messageId", args.id))

View File

@@ -1,5 +1,30 @@
import { query, mutation } from "./_generated/server"; import { query, mutation } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
import { GenericQueryCtx } from "convex/server";
import { DataModel, Id, Doc } from "./_generated/dataModel";
const PERMISSION_KEYS = [
"manage_channels",
"manage_roles",
"create_invite",
"embed_links",
"attach_files",
] as const;
async function getRolesForUser(
ctx: GenericQueryCtx<DataModel>,
userId: Id<"userProfiles">
): Promise<Doc<"roles">[]> {
const assignments = await ctx.db
.query("userRoles")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
const roles = await Promise.all(
assignments.map((ur) => ctx.db.get(ur.roleId))
);
return roles.filter((r): r is Doc<"roles"> => r !== null);
}
// List all roles // List all roles
export const list = query({ export const list = query({
@@ -49,18 +74,17 @@ export const update = mutation({
const role = await ctx.db.get(args.id); const role = await ctx.db.get(args.id);
if (!role) throw new Error("Role not found"); if (!role) throw new Error("Role not found");
const { id, ...fields } = args;
const updates: Record<string, unknown> = {}; const updates: Record<string, unknown> = {};
if (args.name !== undefined) updates.name = args.name; for (const [key, value] of Object.entries(fields)) {
if (args.color !== undefined) updates.color = args.color; if (value !== undefined) updates[key] = value;
if (args.permissions !== undefined) updates.permissions = args.permissions;
if (args.position !== undefined) updates.position = args.position;
if (args.isHoist !== undefined) updates.isHoist = args.isHoist;
if (Object.keys(updates).length > 0) {
await ctx.db.patch(args.id, updates);
} }
return await ctx.db.get(args.id); if (Object.keys(updates).length > 0) {
await ctx.db.patch(id, updates);
}
return await ctx.db.get(id);
}, },
}); });
@@ -72,7 +96,6 @@ export const remove = mutation({
const role = await ctx.db.get(args.id); const role = await ctx.db.get(args.id);
if (!role) throw new Error("Role not found"); if (!role) throw new Error("Role not found");
// Delete user_role assignments
const assignments = await ctx.db const assignments = await ctx.db
.query("userRoles") .query("userRoles")
.withIndex("by_role", (q) => q.eq("roleId", args.id)) .withIndex("by_role", (q) => q.eq("roleId", args.id))
@@ -93,30 +116,14 @@ export const listMembers = query({
handler: async (ctx) => { handler: async (ctx) => {
const users = await ctx.db.query("userProfiles").collect(); const users = await ctx.db.query("userProfiles").collect();
const result = await Promise.all( return await Promise.all(
users.map(async (user) => { users.map(async (user) => ({
const userRoleAssignments = await ctx.db
.query("userRoles")
.withIndex("by_user", (q) => q.eq("userId", user._id))
.collect();
const roles = await Promise.all(
userRoleAssignments.map(async (ur) => {
const role = await ctx.db.get(ur.roleId);
return role;
})
);
return {
id: user._id, id: user._id,
username: user.username, username: user.username,
public_identity_key: user.publicIdentityKey, public_identity_key: user.publicIdentityKey,
roles: roles.filter(Boolean), roles: await getRolesForUser(ctx, user._id),
}; }))
})
); );
return result;
}, },
}); });
@@ -128,7 +135,6 @@ export const assign = mutation({
}, },
returns: v.object({ success: v.boolean() }), returns: v.object({ success: v.boolean() }),
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Check if already assigned
const existing = await ctx.db const existing = await ctx.db
.query("userRoles") .query("userRoles")
.withIndex("by_user_and_role", (q) => .withIndex("by_user_and_role", (q) =>
@@ -181,30 +187,21 @@ export const getMyPermissions = query({
attach_files: v.boolean(), attach_files: v.boolean(),
}), }),
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userRoleAssignments = await ctx.db const roles = await getRolesForUser(ctx, args.userId);
.query("userRoles")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
const finalPerms = { const finalPerms: Record<string, boolean> = {};
manage_channels: false, for (const key of PERMISSION_KEYS) {
manage_roles: false, finalPerms[key] = roles.some(
create_invite: false, (role) => (role.permissions as Record<string, boolean>)?.[key]
embed_links: false, );
attach_files: false,
};
for (const ur of userRoleAssignments) {
const role = await ctx.db.get(ur.roleId);
if (!role) continue;
const p = (role.permissions || {}) as Record<string, boolean>;
if (p.manage_channels) finalPerms.manage_channels = true;
if (p.manage_roles) finalPerms.manage_roles = true;
if (p.create_invite) finalPerms.create_invite = true;
if (p.embed_links) finalPerms.embed_links = true;
if (p.attach_files) finalPerms.attach_files = true;
} }
return finalPerms; return finalPerms as {
manage_channels: boolean;
manage_roles: boolean;
create_invite: boolean;
embed_links: boolean;
attach_files: boolean;
};
}, },
}); });

View File

@@ -2,7 +2,8 @@ import { query, mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
import { internal } from "./_generated/api"; import { internal } from "./_generated/api";
// Start typing indicator const TYPING_TTL_MS = 6000;
export const startTyping = mutation({ export const startTyping = mutation({
args: { args: {
channelId: v.id("channels"), channelId: v.id("channels"),
@@ -11,9 +12,8 @@ export const startTyping = mutation({
}, },
returns: v.null(), returns: v.null(),
handler: async (ctx, args) => { handler: async (ctx, args) => {
const expiresAt = Date.now() + 6000; // 6 second TTL const expiresAt = Date.now() + TYPING_TTL_MS;
// Upsert: check if already exists
const existing = await ctx.db const existing = await ctx.db
.query("typingIndicators") .query("typingIndicators")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId)) .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
@@ -32,14 +32,11 @@ export const startTyping = mutation({
}); });
} }
// Schedule cleanup await ctx.scheduler.runAfter(TYPING_TTL_MS, internal.typing.cleanExpired, {});
await ctx.scheduler.runAfter(6000, internal.typing.cleanExpired, {});
return null; return null;
}, },
}); });
// Stop typing indicator
export const stopTyping = mutation({ export const stopTyping = mutation({
args: { args: {
channelId: v.id("channels"), channelId: v.id("channels"),
@@ -61,7 +58,6 @@ export const stopTyping = mutation({
}, },
}); });
// Get typing users for a channel (reactive!)
export const getTyping = query({ export const getTyping = query({
args: { channelId: v.id("channels") }, args: { channelId: v.id("channels") },
returns: v.array( returns: v.array(
@@ -79,21 +75,17 @@ export const getTyping = query({
return indicators return indicators
.filter((t) => t.expiresAt > now) .filter((t) => t.expiresAt > now)
.map((t) => ({ .map((t) => ({ userId: t.userId, username: t.username }));
userId: t.userId,
username: t.username,
}));
}, },
}); });
// Internal: clean expired typing indicators
export const cleanExpired = internalMutation({ export const cleanExpired = internalMutation({
args: {}, args: {},
returns: v.null(), returns: v.null(),
handler: async (ctx) => { handler: async (ctx) => {
const now = Date.now(); const now = Date.now();
const all = await ctx.db.query("typingIndicators").collect(); const expired = await ctx.db.query("typingIndicators").collect();
for (const t of all) { for (const t of expired) {
if (t.expiresAt <= now) { if (t.expiresAt <= now) {
await ctx.db.delete(t._id); await ctx.db.delete(t._id);
} }

View File

@@ -1,7 +1,16 @@
import { query, mutation } from "./_generated/server"; import { query, mutation } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
// Join voice channel async function removeUserVoiceStates(ctx: any, userId: any) {
const existing = await ctx.db
.query("voiceStates")
.withIndex("by_user", (q: any) => q.eq("userId", userId))
.collect();
for (const vs of existing) {
await ctx.db.delete(vs._id);
}
}
export const join = mutation({ export const join = mutation({
args: { args: {
channelId: v.id("channels"), channelId: v.id("channels"),
@@ -12,17 +21,8 @@ export const join = mutation({
}, },
returns: v.null(), returns: v.null(),
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Remove from any other voice channel first await removeUserVoiceStates(ctx, args.userId);
const existing = await ctx.db
.query("voiceStates")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
for (const vs of existing) {
await ctx.db.delete(vs._id);
}
// Add to new channel
await ctx.db.insert("voiceStates", { await ctx.db.insert("voiceStates", {
channelId: args.channelId, channelId: args.channelId,
userId: args.userId, userId: args.userId,
@@ -36,27 +36,17 @@ export const join = mutation({
}, },
}); });
// Leave voice channel
export const leave = mutation({ export const leave = mutation({
args: { args: {
userId: v.id("userProfiles"), userId: v.id("userProfiles"),
}, },
returns: v.null(), returns: v.null(),
handler: async (ctx, args) => { handler: async (ctx, args) => {
const existing = await ctx.db await removeUserVoiceStates(ctx, args.userId);
.query("voiceStates")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
for (const vs of existing) {
await ctx.db.delete(vs._id);
}
return null; return null;
}, },
}); });
// Update mute/deafen/screenshare state
export const updateState = mutation({ export const updateState = mutation({
args: { args: {
userId: v.id("userProfiles"), userId: v.id("userProfiles"),
@@ -69,47 +59,36 @@ export const updateState = mutation({
const existing = await ctx.db const existing = await ctx.db
.query("voiceStates") .query("voiceStates")
.withIndex("by_user", (q) => q.eq("userId", args.userId)) .withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect(); .first();
if (existing.length > 0) { if (existing) {
const updates: Record<string, boolean> = {}; const { userId: _, ...updates } = args;
if (args.isMuted !== undefined) updates.isMuted = args.isMuted; const filtered = Object.fromEntries(
if (args.isDeafened !== undefined) updates.isDeafened = args.isDeafened; Object.entries(updates).filter(([, val]) => val !== undefined)
if (args.isScreenSharing !== undefined) );
updates.isScreenSharing = args.isScreenSharing; await ctx.db.patch(existing._id, filtered);
await ctx.db.patch(existing[0]._id, updates);
} }
return null; return null;
}, },
}); });
// Get all voice states (reactive!)
export const getAll = query({ export const getAll = query({
args: {}, args: {},
returns: v.any(), returns: v.any(),
handler: async (ctx) => { handler: async (ctx) => {
const states = await ctx.db.query("voiceStates").collect(); const states = await ctx.db.query("voiceStates").collect();
// Group by channel const grouped: Record<string, Array<{
const grouped: Record<
string,
Array<{
userId: string; userId: string;
username: string; username: string;
isMuted: boolean; isMuted: boolean;
isDeafened: boolean; isDeafened: boolean;
isScreenSharing: boolean; isScreenSharing: boolean;
}> }>> = {};
> = {};
for (const s of states) { for (const s of states) {
const channelId = s.channelId; (grouped[s.channelId] ??= []).push({
if (!grouped[channelId]) {
grouped[channelId] = [];
}
grouped[channelId].push({
userId: s.userId, userId: s.userId,
username: s.username, username: s.username,
isMuted: s.isMuted, isMuted: s.isMuted,

View File

@@ -0,0 +1,836 @@
element.style {
font-size: 100%;
--saturation-factor: 1;
dynamic-range-limit: no-limit;
--custom-zoom: 100;
--devtools-sidebar-width: 0px;
}
.mana-toggle-inputs .theme-darker, .mana-toggle-inputs .theme-midnight, .mana-toggle-inputs.theme-darker, .mana-toggle-inputs.theme-midnight {
--checkbox-background-default: hsl(var(--opacity-black-8-hsl) / 0.0784313725490196);
--checkbox-border-default: hsl(var(--opacity-64-hsl) / 0.6392156862745098);
}
.mana-toggle-inputs .theme-dark, .mana-toggle-inputs.theme-dark {
--checkbox-background-default: hsl(var(--opacity-black-8-hsl) / 0.0784313725490196);
--checkbox-border-default: hsl(var(--opacity-64-hsl) / 0.6392156862745098);
}
:root {
--custom-bg-surface-overlay: rgba(33, 34, 41, .8);
}
:root {
--custom-bg-surface-overlay: rgba(33, 34, 41, .8);
}
.theme-dark {
--legacy-elevation-low: 0 1px 5px 0 var(--opacity-black-28);
--legacy-elevation-high: 0 2px 10px 0 var(--opacity-black-20);
--legacy-elevation-border: 0 0 0 1px hsl(var(--primary-700-hsl) / 0.6);
}
:root {
--legacy-elevation-low: 0 1px 5px var(--opacity-black-20);
--legacy-elevation-high: 0 2px 10px 0 var(--opacity-black-8);
--legacy-elevation-border: 0 0 0 1px hsl(var(--primary-300-hsl) / 0.3);
}
:root {
--custom-premium-marketing-hero-heading-padding-top: 120px;
}
:root {
--custom-app-message-embed-base-info-gap: 4px;
--custom-app-message-embed-base-info-top: calc(var(--custom-app-message-embed-base-info-gap) - 2px);
}
:root {
--custom-app-message-embed-base-info-gap: 4px;
--custom-app-message-embed-base-info-top: calc(var(--custom-app-message-embed-base-info-gap) - 2px);
}
:root {
--custom-app-message-embed-base-info-gap: 4px;
--custom-app-message-embed-base-info-top: calc(var(--custom-app-message-embed-base-info-gap) - 2px);
}
:root {
--custom-app-message-embed-base-info-gap: 4px;
--custom-app-message-embed-base-info-top: calc(var(--custom-app-message-embed-base-info-gap) - 2px);
}
:root {
--custom-guild-list-padding: var(--space-md);
--custom-guild-list-width: calc(var(--guildbar-avatar-size) + var(--custom-guild-list-padding) * 2);
--custom-guild-sidebar-width: 268px;
--custom-app-sidebar-target-width: calc(var(--custom-guild-sidebar-width) + var(--custom-guild-list-width));
--custom-rtc-account-height: 44px;
--custom-app-top-bar-height: 32px;
--custom-app-top-bar-item-radius: 6px;
--custom-channel-header-height: calc(var(--guildbar-avatar-size) + var(--space-xs));
--custom-member-list-width: 264px;
--custom-channel-textarea-text-area-height: 56px;
--custom-chat-aligned-icon-offset: ((var(--chat-avatar-size) - var(--chat-input-icon-size)) / 2);
--custom-message-margin-horizontal: var(--space-md);
}
:root {
--custom-add-permissions-modal-focus-ring-width: 4px;
--custom-custom-role-icon-form-item-role-icon-preview-size: 32px;
--custom-guild-settings-roles-edit-shared-sidebar-width: 232px;
--custom-guild-settings-roles-intro-roles-transition: 250ms;
--custom-guild-settings-roles-intro-pause-transition: 166ms;
--custom-guild-settings-roles-intro-background-transition: 500ms;
--custom-guild-settings-roles-intro-banner-transition-delay: calc(var(--custom-guild-settings-roles-intro-roles-transition) + var(--custom-guild-settings-roles-intro-pause-transition));
--custom-guild-settings-roles-intro-roles-transition-delay: calc(var(--custom-guild-settings-roles-intro-roles-transition) + var(--custom-guild-settings-roles-intro-pause-transition) * 2 + var(--custom-guild-settings-roles-intro-background-transition));
--custom-guild-settings-community-intro-content-spacing: 32px;
--custom-guild-settings-community-intro-hover-distance: -12px;
--custom-guild-settings-community-intro-text-spacing: 8px;
--custom-guild-settings-discovery-landing-page-max-width-tab: 905px;
--custom-guild-settings-discovery-landing-page-settings-max-width: 520px;
--custom-guild-settings-partner-content-spacing: 32px;
--custom-event-detail-info-tab-base-spacing: 8px;
--custom-subscription-listing-previews-carousel-cards-get-cut-off-width: 724px;
--custom-editable-benefits-list-emoji-size: 24px;
--custom-edit-benefit-modal-emoji-size: 22px;
--custom-edit-benefit-modal-emoji-margin: 10px;
--custom-guild-settings-role-subscriptions-max-width: 905px;
--custom-guild-settings-role-subscriptions-overview-settings-max-width: 520px;
--custom-guild-settings-store-page-settings-max-width: 520px;
--custom-importable-benefits-list-listing-image-size: 40px;
--custom-import-benefits-modal-icon-size: 24px;
--custom-import-benefits-modal-role-icon-size: 40px;
--custom-role-icon-uploader-icon-size: 24px;
--custom-guild-role-subscription-style-constants-cover-image-aspect-ratio: 4;
--custom-historic-earnings-table-toggle-expand-column-width: 30px;
--custom-guild-role-subscription-card-basic-info-tier-image-size: 80px;
--custom-guild-role-subscription-card-basic-info-tier-image-size-mobile: 48px;
--custom-guild-role-subscriptions-overview-page-page-max-width: 1180px;
--custom-guild-dialog-popout-width: 250px;
--custom-guild-dialog-splash-ratio: 1.77778;
--custom-guild-dialog-icon-size: 84px;
--custom-guild-dialog-icon-padding: 4px;
--custom-guild-product-download-modal-header-image-width: 119px;
--custom-guild-onboarding-home-page-max-page-width: 1128px;
--custom-guild-onboarding-home-page-max-single-column-width: 704px;
--custom-home-resource-channels-obscured-blur-radius: 20px;
--custom-guild-member-application-review-sidebar-width: 29vw;
--custom-featured-items-popout-featured-items-popout-footer-height: 120px;
--custom-guild-boosting-sidebar-display-conditional-bottom-margin: 12px;
--custom-guild-boosting-marketing-progress-bar-marker-dimensions: 32px;
--custom-guild-boosting-marketing-progress-bar-end-markers-margin: 4px;
--custom-guild-boosting-marketing-progress-bar-marker-marker-dimensions: 32px;
--custom-guild-boosting-marketing-tier-cards-tier-card-border-radius: 16px;
--custom-go-live-modal-art-height: 112px;
--custom-gif-picker-gutter-size: 0 16px 12px 16px;
--custom-gif-picker-search-results-desired-item-width: 160px;
--custom-forum-composer-attachments-attachment-size: 78px;
Show all properties (149 more)
}
:root {
--custom-index-scrollbar-width: 10px;
--custom-index-scrollbar-margin: 3px;
--custom-auth-box-auth-box-padding: 32px;
--custom-wave-splash-responsive-width-mobile: 485px;
--custom-wave-splash-responsive-width-mobile-first: 486px;
--custom-wave-splash-responsive-width-desktop: 1080px;
--custom-wave-splash-max-qr-login-width: 830px;
--custom-channel-text-area-button-hover-scale: 0.85714;
--custom-drag-resize-container-handle-size: 8px;
--custom-drag-resize-container-handle-bleed: 2px;
--custom-drag-resize-container-handle-offset: calc(var(--custom-drag-resize-container-handle-bleed) - var(--custom-drag-resize-container-handle-size));
--custom-embed-spoiler-blur-radius: 44px;
--custom-gradient-progress-notch-width: 8px;
--custom-gradient-progress-notch-height: 16px;
--custom-gradient-progress-notch-margin: 2px;
--custom-guild-discovery-card-card-height: 320px;
--custom-guild-discovery-card-card-height-with-tags: 350px;
--custom-icon-button-icon-lg-size: 36px;
--custom-icon-button-icon-md-size: 24px;
--custom-icon-button-icon-sm-size: 18px;
--custom-icon-button-icon-xs-size: 12px;
--custom-invite-button-resolving-background-width: 380px;
--custom-keybind-space-around-key: 8px;
--custom-keybind-shadow-width: 2px;
--custom-keybind-vertical-padding-total-height: 8px;
--custom-keybind-applied-vertical-padding: calc((var(--custom-keybind-vertical-padding-total-height) - var(--custom-keybind-shadow-width)) / 2);
--custom-full-screen-layer-animation-duration: 150ms;
--custom-layout-sidebar-width: 232px;
--custom-message-avatar-size: 40px;
--custom-message-avatar-decoration-size: calc(var(--custom-message-avatar-size) * var(--decoration-to-avatar-ratio));
--custom-message-margin-compact-indent: 5rem;
--custom-message-spacing-vertical-container-cozy: 0.125rem;
--custom-message-padding-vertical-container-compact: 0.125rem;
--custom-message-meta-space: 0.25rem;
--custom-message-reply-indent: 0.625rem;
--custom-message-margin-left-content-cozy: calc(var(--custom-message-avatar-size, 40px) + var(--custom-message-margin-horizontal) + var(--custom-message-margin-horizontal));
--custom-message-reply-message-preview-line-height: 1.125rem;
--custom-message-attachment-spoiler-blur-radius: 44px;
--custom-user-premium-guild-subscription-easter-egg-size: 196px;
--custom-notification-spacing: 12px;
--custom-notification-container-width: 300px;
--custom-notification-space-around-divider: 12px;
--custom-notification-box-shadow-opacity: 0.8;
--custom-notification-box-shadow-blur-radius: 7px;
--custom-notification-box-shadow-spread-radius: 3px;
--custom-widget-max-widget-height: 100vh;
--custom-widget-bar-padding: 12px;
--custom-widget-body-padding: 4px;
--custom-widget-bar-height: 20px;
--custom-premium-guild-progress-bar-progress-bar-width: 24px;
Show all properties (113 more)
}
.density-default {
--channels-name-line-height: 24px;
--channels-spine-inverted-offset-top: 6px;
--channels-spine-offset-left: 24px;
}
:root {
--font-weight-light: 300;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--font-weight-extra-bold: 800;
--channels-name-line-height: 24px;
--channels-spine-inverted-offset-top: 6px;
--channels-spine-offset-left: 24px;
--chat-avatar-size: 40px;
--chat-input-icon-size: 20px;
--chat-markup-line-height: 1.375rem;
--chat-resize-handle-width: 8px;
--control-input-height-md: 40px;
--control-input-height-sm: 32px;
--control-item-height-md: 40px;
--control-item-height-sm: 32px;
--form-input-height: 44px;
--guildbar-avatar-size: 40px;
--guildbar-folder-size: 48px;
--icon-size-lg: 32px;
--icon-size-md: 24px;
--icon-size-sm: 18px;
--icon-size-xs: 16px;
--icon-size-xxs: 12px;
--modal-horizontal-padding: 24px;
--modal-vertical-padding: 16px;
--modal-width-large: 800px;
--modal-width-medium: 602px;
--modal-width-small: 442px;
--select-max-width: 248px;
--select-option-height: 40px;
}
:root {
--font-primary: "gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-display: "gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-headline: "ABC Ginto Nord", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-code: "gg mono", "Source Code Pro", Consolas, "Andale Mono WT", "Andale Mono", "Lucida Console", "Lucida Sans Typewriter", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", "Liberation Mono", "Nimbus Mono L", Monaco, "Courier New", Courier, monospace;
--font-clan-body: Fraunces, "gg sans", serif, "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-clan-signature: Corinthia, "gg sans", cursive, "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-display-marketing: "ABC Ginto Discord", "gg sans", serif, "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-display-marketing-header: "ABC Ginto Nord Discord", "gg sans", serif, "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
.theme-dark {
--guild-header-text-shadow: 0 1px 1px hsl(var(--black-hsl) / 0.4);
--elevation-stroke: 0 0 0 1px hsl(var(--primary-900-hsl) / 0.15);
--elevation-low: 0 1px 0 hsl(var(--primary-900-hsl) / 0.2), 0 1.5px 0 hsl(var(--primary-860-hsl) / 0.05), 0 2px 0 hsl(var(--primary-900-hsl) / 0.05);
--elevation-medium: 0 4px 4px hsl(var(--black-hsl) / 0.16);
--elevation-high: 0 8px 16px hsl(var(--black-hsl) / 0.24);
}
:root {
--radius-none: 0px;
--radius-xs: 4px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
--radius-xxl: 32px;
--radius-round: 2147483647px;
}
.density-default {
--space-xxs: var(--space-4);
--space-xs: var(--space-8);
--space-sm: var(--space-12);
--space-md: var(--space-16);
--space-lg: var(--space-20);
--space-xl: var(--space-24);
--space-xxl: var(--space-32);
}
:root {
--size-0: 0px;
--size-4: 4px;
--size-8: 8px;
--size-12: 12px;
--size-16: 16px;
--size-20: 20px;
--size-24: 24px;
--size-32: 32px;
--size-48: 48px;
--size-64: 64px;
--size-80: 80px;
--size-96: 96px;
--size-128: 128px;
--size-160: 160px;
--size-192: 192px;
--size-xxs: var(--size-4);
--size-xs: var(--size-8);
--size-sm: var(--size-12);
--size-md: var(--size-16);
--size-lg: var(--size-20);
--size-xl: var(--size-24);
--size-xxl: var(--size-32);
--breakpoint-480: 480px;
--breakpoint-640: 640px;
--breakpoint-768: 768px;
--breakpoint-1024: 1024px;
--breakpoint-1280: 1280px;
--breakpoint-1536: 1536px;
--breakpoint-1800: 1800px;
--breakpoint-2500: 2500px;
--breakpoint-xxs: 480px;
--breakpoint-xs: 640px;
--breakpoint-sm: 768px;
--breakpoint-md: 1024px;
--breakpoint-lg: 1280px;
--breakpoint-xl: 1536px;
--breakpoint-xxl: 1800px;
--breakpoint-max: 2500px;
--space-0: 0px;
--space-4: 4px;
--space-6: 6px;
--space-8: 8px;
--space-10: 10px;
--space-12: 12px;
--space-16: 16px;
--space-20: 20px;
--space-24: 24px;
--space-26: 26px;
--space-30: 30px;
--space-32: 32px;
Show all properties (15 more)
}
.theme-darker, .theme-midnight {
--shadow-border: 0 0 0 1px hsl(none 0% 100% / 0.08);
--shadow-border-filter: drop-shadow(0 0 1px hsl(none 0% 100% / 0.08));
--shadow-button-overlay: 0 12px 24px 0 hsl(none 0% 0% / 0.24);
--shadow-button-overlay-filter: drop-shadow(0 12px 24px hsl(none 0% 0% / 0.24));
--shadow-high: 0 12px 24px 0 hsl(none 0% 0% / 0.24);
--shadow-high-filter: drop-shadow(0 12px 24px hsl(none 0% 0% / 0.24));
--shadow-ledge: 0 2px 0 0 hsl(none 0% 0% / 0.05), 0 1.5px 0 0 hsl(none 0% 0% / 0.05), 0 1px 0 0 hsl(none 0% 0% / 0.16);
--shadow-ledge-filter: drop-shadow(0 1.5px 0 hsl(none 0% 0% / 0.24));
--shadow-low: 0 1px 4px 0 hsl(none 0% 0% / 0.14);
--shadow-low-filter: drop-shadow(0 1px 4px hsl(none 0% 0% / 0.14));
--shadow-low-active: 0 0 4px 0 hsl(none 0% 0% / 0.14);
--shadow-low-active-filter: drop-shadow(0 0 4px hsl(none 0% 0% / 0.14));
--shadow-low-hover: 0 4px 10px 0 hsl(none 0% 0% / 0.14);
--shadow-low-hover-filter: drop-shadow(0 4px 10px hsl(none 0% 0% / 0.14));
--shadow-medium: 0 4px 8px 0 hsl(none 0% 0% / 0.16);
--shadow-medium-filter: drop-shadow(0 4px 8px hsl(none 0% 0% / 0.16));
--shadow-mobile-navigator-x: 0 0 10px 0 hsl(none 0% 0% / 0.22);
--shadow-mobile-navigator-x-filter: drop-shadow(0 0 10px hsl(none 0% 0% / 0.22));
--shadow-top-high: 0 -12px 32px 0 hsl(none 0% 0% / 0.24);
--shadow-top-high-filter: drop-shadow(0 -12px 32px hsl(none 0% 0% / 0.24));
--shadow-top-ledge: 0 -2px 0 0 hsl(none 0% 0% / 0.05), 0 -1.5px 0 0 hsl(none 0% 0% / 0.05), 0 -1px 0 0 hsl(none 0% 0% / 0.16);
--shadow-top-ledge-filter: drop-shadow(0 -1.5px 0 hsl(none 0% 0% / 0.24));
--shadow-top-low: 0 -1px 4px 0 hsl(none 0% 0% / 0.14);
--shadow-top-low-filter: drop-shadow(0 -1px 4px hsl(none 0% 0% / 0.14));
}
.theme-dark {
--shadow-border: 0 0 0 1px hsl(none 0% 100% / 0.08);
--shadow-border-filter: drop-shadow(0 0 1px hsl(none 0% 100% / 0.08));
--shadow-button-overlay: 0 12px 24px 0 hsl(none 0% 0% / 0.24);
--shadow-button-overlay-filter: drop-shadow(0 12px 24px hsl(none 0% 0% / 0.24));
--shadow-high: 0 12px 24px 0 hsl(none 0% 0% / 0.24);
--shadow-high-filter: drop-shadow(0 12px 24px hsl(none 0% 0% / 0.24));
--shadow-ledge: 0 2px 0 0 hsl(none 0% 0% / 0.05), 0 1.5px 0 0 hsl(none 0% 0% / 0.05), 0 1px 0 0 hsl(none 0% 0% / 0.16);
--shadow-ledge-filter: drop-shadow(0 1.5px 0 hsl(none 0% 0% / 0.24));
--shadow-low: 0 1px 4px 0 hsl(none 0% 0% / 0.14);
--shadow-low-filter: drop-shadow(0 1px 4px hsl(none 0% 0% / 0.14));
--shadow-low-active: 0 0 4px 0 hsl(none 0% 0% / 0.14);
--shadow-low-active-filter: drop-shadow(0 0 4px hsl(none 0% 0% / 0.14));
--shadow-low-hover: 0 4px 10px 0 hsl(none 0% 0% / 0.14);
--shadow-low-hover-filter: drop-shadow(0 4px 10px hsl(none 0% 0% / 0.14));
--shadow-medium: 0 4px 8px 0 hsl(none 0% 0% / 0.16);
--shadow-medium-filter: drop-shadow(0 4px 8px hsl(none 0% 0% / 0.16));
--shadow-mobile-navigator-x: 0 0 10px 0 hsl(none 0% 0% / 0.22);
--shadow-mobile-navigator-x-filter: drop-shadow(0 0 10px hsl(none 0% 0% / 0.22));
--shadow-top-high: 0 -12px 32px 0 hsl(none 0% 0% / 0.24);
--shadow-top-high-filter: drop-shadow(0 -12px 32px hsl(none 0% 0% / 0.24));
--shadow-top-ledge: 0 -2px 0 0 hsl(none 0% 0% / 0.05), 0 -1.5px 0 0 hsl(none 0% 0% / 0.05), 0 -1px 0 0 hsl(none 0% 0% / 0.16);
--shadow-top-ledge-filter: drop-shadow(0 -1.5px 0 hsl(none 0% 0% / 0.24));
--shadow-top-low: 0 -1px 4px 0 hsl(none 0% 0% / 0.14);
--shadow-top-low-filter: drop-shadow(0 -1px 4px hsl(none 0% 0% / 0.14));
}
.visual-refresh {
--blue-100: var(--blue-new-1);
--blue-100-hsl: var(--blue-new-1-hsl);
--blue-130: var(--blue-new-1);
--blue-130-hsl: var(--blue-new-1-hsl);
--blue-160: var(--blue-new-1);
--blue-160-hsl: var(--blue-new-1-hsl);
--blue-200: var(--blue-new-5);
--blue-200-hsl: var(--blue-new-5-hsl);
--blue-230: var(--blue-new-11);
--blue-230-hsl: var(--blue-new-11-hsl);
--blue-260: var(--blue-new-16);
--blue-260-hsl: var(--blue-new-16-hsl);
--blue-300: var(--blue-new-24);
--blue-300-hsl: var(--blue-new-24-hsl);
--blue-330: var(--blue-new-30);
--blue-330-hsl: var(--blue-new-30-hsl);
--blue-345: var(--blue-new-36);
--blue-345-hsl: var(--blue-new-36-hsl);
--blue-360: var(--blue-new-40);
--blue-360-hsl: var(--blue-new-40-hsl);
--blue-400: var(--blue-new-46);
--blue-400-hsl: var(--blue-new-46-hsl);
--blue-430: var(--blue-new-52);
--blue-430-hsl: var(--blue-new-52-hsl);
--blue-460: var(--blue-new-57);
--blue-460-hsl: var(--blue-new-57-hsl);
--blue-500: var(--blue-new-62);
--blue-500-hsl: var(--blue-new-62-hsl);
--blue-530: var(--blue-new-67);
--blue-530-hsl: var(--blue-new-67-hsl);
--blue-560: var(--blue-new-71);
--blue-560-hsl: var(--blue-new-71-hsl);
--blue-600: var(--blue-new-75);
--blue-600-hsl: var(--blue-new-75-hsl);
--blue-630: var(--blue-new-78);
--blue-630-hsl: var(--blue-new-78-hsl);
--blue-660: var(--blue-new-81);
--blue-660-hsl: var(--blue-new-81-hsl);
--blue-700: var(--blue-new-84);
--blue-700-hsl: var(--blue-new-84-hsl);
--blue-730: var(--blue-new-87);
--blue-730-hsl: var(--blue-new-87-hsl);
--blue-760: var(--blue-new-90);
--blue-760-hsl: var(--blue-new-90-hsl);
--blue-800: var(--blue-new-92);
--blue-800-hsl: var(--blue-new-92-hsl);
--blue-830: var(--blue-new-94);
--blue-830-hsl: var(--blue-new-94-hsl);
--blue-860: var(--blue-new-95);
--blue-860-hsl: var(--blue-new-95-hsl);
Show all properties (422 more)
}
:root {
--neutral-1: hsl(var(--neutral-1-hsl) / 1);
--neutral-1-hsl: 0 calc(var(--saturation-factor, 1) * 0%) 100%;
--neutral-2: hsl(var(--neutral-2-hsl) / 1);
--neutral-2-hsl: 0 calc(var(--saturation-factor, 1) * 0%) 98.431%;
--neutral-3: hsl(var(--neutral-3-hsl) / 1);
--neutral-3-hsl: 240 calc(var(--saturation-factor, 1) * 6.667%) 97.059%;
--neutral-4: hsl(var(--neutral-4-hsl) / 1);
--neutral-4-hsl: 240 calc(var(--saturation-factor, 1) * 4.348%) 95.49%;
--neutral-5: hsl(var(--neutral-5-hsl) / 1);
--neutral-5-hsl: 240 calc(var(--saturation-factor, 1) * 6.667%) 94.118%;
--neutral-6: hsl(var(--neutral-6-hsl) / 1);
--neutral-6-hsl: 210 calc(var(--saturation-factor, 1) * 5.263%) 92.549%;
--neutral-7: hsl(var(--neutral-7-hsl) / 1);
--neutral-7-hsl: 240 calc(var(--saturation-factor, 1) * 4.545%) 91.373%;
--neutral-8: hsl(var(--neutral-8-hsl) / 1);
--neutral-8-hsl: 240 calc(var(--saturation-factor, 1) * 3.846%) 89.804%;
--neutral-9: hsl(var(--neutral-9-hsl) / 1);
--neutral-9-hsl: 240 calc(var(--saturation-factor, 1) * 5.085%) 88.431%;
--neutral-10: hsl(var(--neutral-10-hsl) / 1);
--neutral-10-hsl: 240 calc(var(--saturation-factor, 1) * 4.478%) 86.863%;
--neutral-11: hsl(var(--neutral-11-hsl) / 1);
--neutral-11-hsl: 225 calc(var(--saturation-factor, 1) * 5.405%) 85.49%;
--neutral-12: hsl(var(--neutral-12-hsl) / 1);
--neutral-12-hsl: 225 calc(var(--saturation-factor, 1) * 4.878%) 83.922%;
--neutral-13: hsl(var(--neutral-13-hsl) / 1);
--neutral-13-hsl: 240 calc(var(--saturation-factor, 1) * 4.545%) 82.745%;
--neutral-14: hsl(var(--neutral-14-hsl) / 1);
--neutral-14-hsl: 240 calc(var(--saturation-factor, 1) * 4.167%) 81.176%;
--neutral-15: hsl(var(--neutral-15-hsl) / 1);
--neutral-15-hsl: 228 calc(var(--saturation-factor, 1) * 4.854%) 79.804%;
--neutral-16: hsl(var(--neutral-16-hsl) / 1);
--neutral-16-hsl: 228 calc(var(--saturation-factor, 1) * 4.505%) 78.235%;
--neutral-17: hsl(var(--neutral-17-hsl) / 1);
--neutral-17-hsl: 240 calc(var(--saturation-factor, 1) * 4.274%) 77.059%;
--neutral-18: hsl(var(--neutral-18-hsl) / 1);
--neutral-18-hsl: 240 calc(var(--saturation-factor, 1) * 4%) 75.49%;
--neutral-19: hsl(var(--neutral-19-hsl) / 1);
--neutral-19-hsl: 230 calc(var(--saturation-factor, 1) * 4.545%) 74.118%;
--neutral-20: hsl(var(--neutral-20-hsl) / 1);
--neutral-20-hsl: 230 calc(var(--saturation-factor, 1) * 4.286%) 72.549%;
--neutral-21: hsl(var(--neutral-21-hsl) / 1);
--neutral-21-hsl: 240 calc(var(--saturation-factor, 1) * 4.11%) 71.373%;
--neutral-22: hsl(var(--neutral-22-hsl) / 1);
--neutral-22-hsl: 231.429 calc(var(--saturation-factor, 1) * 4.575%) 70%;
--neutral-23: hsl(var(--neutral-23-hsl) / 1);
--neutral-23-hsl: 231.429 calc(var(--saturation-factor, 1) * 4.348%) 68.431%;
--neutral-24: hsl(var(--neutral-24-hsl) / 1);
--neutral-24-hsl: 240 calc(var(--saturation-factor, 1) * 4.192%) 67.255%;
--neutral-25: hsl(var(--neutral-25-hsl) / 1);
--neutral-25-hsl: 231.429 calc(var(--saturation-factor, 1) * 4%) 65.686%;
Show all properties (2780 more)
}
@supports (color:color-mix(in lch,red,blue)) {
.theme-darker {
--app-frame-background: color-mix(in oklab, var(--neutral-97) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--app-frame-border: color-mix(in oklab, hsl(var(--opacity-12-hsl) / 0.12156862745098039) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.12156862745098039) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--app-message-embed-secondary-text: color-mix(in oklab, hsl(var(--white-hsl) / 0.7) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.7) var(--custom-theme-text-color-amount, 0%));
--background-accent: color-mix(in oklab, var(--plum-15) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-base-low: color-mix(in oklab, var(--neutral-82) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-base-lower: color-mix(in oklab, var(--neutral-86) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-base-lowest: color-mix(in oklab, var(--neutral-92) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-code: color-mix(in oklab, hsl(var(--opacity-blurple-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-code-addition: color-mix(in oklab, hsl(var(--opacity-green-12-hsl) / 0.12156862745098039) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.12156862745098039) var(--custom-theme-base-color-amount, 0%));
--background-code-deletion: color-mix(in oklab, hsl(var(--opacity-red-12-hsl) / 0.12156862745098039) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.12156862745098039) var(--custom-theme-base-color-amount, 0%));
--background-feedback-critical: color-mix(in oklab, hsl(var(--opacity-red-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-feedback-info: color-mix(in oklab, hsl(var(--opacity-blue-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-feedback-positive: color-mix(in oklab, hsl(var(--opacity-green-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-feedback-warning: color-mix(in oklab, hsl(var(--opacity-yellow-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-scrim: color-mix(in oklab, hsl(var(--opacity-black-72-hsl) / 0.7215686274509804) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.7215686274509804) var(--custom-theme-base-color-amount, 0%));
--background-scrim-lightbox: color-mix(in oklab, hsl(var(--opacity-black-92-hsl) / 0.9215686274509803) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.9215686274509803) var(--custom-theme-base-color-amount, 0%));
--background-secondary-alt: color-mix(in oklab, var(--plum-15) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-surface-high: color-mix(in oklab, var(--neutral-79) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-surface-higher: color-mix(in oklab, var(--neutral-76) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-surface-highest: color-mix(in oklab, var(--neutral-73) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-tile-gradient-pink-end: color-mix(in oklab, hsl(var(--illo-pink-70-hsl) / 0.3) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.3) var(--custom-theme-base-color-amount, 0%));
--background-tile-gradient-pink-start: color-mix(in oklab, hsl(var(--illo-pink-50-hsl) / 0.3) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.3) var(--custom-theme-base-color-amount, 0%));
--bg-surface-raised: color-mix(in oklab, var(--plum-18) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--border-feedback-critical: color-mix(in oklab, hsl(var(--opacity-red-20-hsl) / 0.2) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.2) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-focus: color-mix(in oklab, var(--blue-new-30) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-muted: color-mix(in oklab, hsl(var(--opacity-4-hsl) / 0.0392156862745098) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0392156862745098) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-normal: color-mix(in oklab, hsl(var(--opacity-20-hsl) / 0.2) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.2) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-strong: color-mix(in oklab, hsl(var(--opacity-44-hsl) / 0.4392156862745098) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.4392156862745098) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-subtle: color-mix(in oklab, hsl(var(--opacity-12-hsl) / 0.12156862745098039) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.12156862745098039) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--card-background-default: color-mix(in oklab, var(--neutral-79) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--card-primary-pressed-bg: color-mix(in oklab, var(--plum-19) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--card-secondary-bg: color-mix(in oklab, hsl(var(--opacity-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--card-secondary-pressed-bg: color-mix(in oklab, var(--plum-21) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--channel-icon: color-mix(in oklab, var(--neutral-35) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--channel-text-area-placeholder: color-mix(in oklab, var(--plum-11) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--channels-default: color-mix(in oklab, var(--neutral-35) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--channeltextarea-background: color-mix(in oklab, var(--plum-15) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--chat-background: color-mix(in oklab, var(--plum-16) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--chat-background-default: color-mix(in oklab, var(--neutral-80) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--chat-border: color-mix(in oklab, var(--plum-20) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--chat-text-muted: color-mix(in oklab, var(--neutral-35) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--content-inventory-media-seekbar-container: color-mix(in oklab, hsl(var(--plum-6-hsl) / 0.24) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.24) var(--custom-theme-base-color-amount, 0%));
--content-inventory-overlay-text-primary: color-mix(in oklab, hsl(var(--white-hsl) / 0.85) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.85) var(--custom-theme-text-color-amount, 0%));
--content-inventory-overlay-text-secondary: color-mix(in oklab, hsl(var(--white-hsl) / 0.7) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.7) var(--custom-theme-text-color-amount, 0%));
--context-menu-backdrop-background: color-mix(in oklab, hsl(var(--opacity-black-72-hsl) / 0.7215686274509804) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.7215686274509804) var(--custom-theme-base-color-amount, 0%));
--control-brand-foreground: color-mix(in oklab, var(--brand-360) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--control-brand-foreground-new: color-mix(in oklab, var(--brand-360) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--control-secondary-border-active: color-mix(in oklab, hsl(var(--opacity-4-hsl) / 0.0392156862745098) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0392156862745098) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--control-secondary-border-default: color-mix(in oklab, hsl(var(--opacity-4-hsl) / 0.0392156862745098) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0392156862745098) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--creator-revenue-icon-gradient-end: color-mix(in oklab, var(--teal-430) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
Show all properties (171 more)
}
}
.theme-darker {
--app-frame-background: var(--neutral-97);
--app-frame-border: hsl(var(--opacity-12-hsl) / 0.12156862745098039);
--app-message-embed-secondary-text: hsl(var(--white-hsl) / 0.7);
--background-accent: var(--plum-15);
--background-base-low: var(--neutral-82);
--background-base-lower: var(--neutral-86);
--background-base-lowest: var(--neutral-92);
--background-brand: var(--blurple-50);
--background-code: hsl(var(--opacity-blurple-8-hsl) / 0.0784313725490196);
--background-code-addition: hsl(var(--opacity-green-12-hsl) / 0.12156862745098039);
--background-code-deletion: hsl(var(--opacity-red-12-hsl) / 0.12156862745098039);
--background-feedback-critical: hsl(var(--opacity-red-8-hsl) / 0.0784313725490196);
--background-feedback-info: hsl(var(--opacity-blue-8-hsl) / 0.0784313725490196);
--background-feedback-notification: var(--red-new-46);
--background-feedback-positive: hsl(var(--opacity-green-8-hsl) / 0.0784313725490196);
--background-feedback-warning: hsl(var(--opacity-yellow-8-hsl) / 0.0784313725490196);
--background-mod-muted: hsl(var(--opacity-4-hsl) / 0.0392156862745098);
--background-mod-normal: hsl(var(--opacity-16-hsl) / 0.1607843137254902);
--background-mod-strong: hsl(var(--opacity-20-hsl) / 0.2);
--background-mod-subtle: hsl(var(--opacity-8-hsl) / 0.0784313725490196);
--background-scrim: hsl(var(--opacity-black-72-hsl) / 0.7215686274509804);
--background-scrim-lightbox: hsl(var(--opacity-black-92-hsl) / 0.9215686274509803);
--background-secondary-alt: var(--plum-15);
--background-surface-high: var(--neutral-79);
--background-surface-higher: var(--neutral-76);
--background-surface-highest: var(--neutral-73);
--background-tile-gradient-pink-end: hsl(var(--illo-pink-70-hsl) / 0.3);
--background-tile-gradient-pink-start: hsl(var(--illo-pink-50-hsl) / 0.3);
--badge-background-brand: var(--blurple-50);
--badge-background-default: hsl(var(--opacity-16-hsl) / 0.1607843137254902);
--badge-expressive-background-default: var(--neutral-1);
--badge-expressive-text-default: var(--neutral-71);
--badge-notification-background: var(--red-new-46);
--badge-text-brand: var(--neutral-1);
--badge-text-default: var(--neutral-2);
--bg-surface-raised: var(--plum-18);
--border-feedback-critical: hsl(var(--opacity-red-20-hsl) / 0.2);
--border-focus: var(--blue-new-30);
--border-muted: hsl(var(--opacity-4-hsl) / 0.0392156862745098);
--border-normal: hsl(var(--opacity-20-hsl) / 0.2);
--border-strong: hsl(var(--opacity-44-hsl) / 0.4392156862745098);
--border-subtle: hsl(var(--opacity-12-hsl) / 0.12156862745098039);
--button-danger-background-disabled: var(--red-new-50);
--button-outline-brand-background-hover: var(--brand-500);
--button-outline-brand-border-active: var(--brand-560);
--button-outline-primary-text: var(--white);
--card-background-default: var(--neutral-79);
--card-primary-pressed-bg: var(--plum-19);
--card-secondary-bg: hsl(var(--opacity-8-hsl) / 0.0784313725490196);
--card-secondary-pressed-bg: var(--plum-21);
Show all properties (396 more)
}
@supports (color:color-mix(in lch,red,blue)) {
.theme-dark {
--app-frame-background: color-mix(in oklab, var(--neutral-78) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--app-frame-border: color-mix(in oklab, hsl(var(--opacity-12-hsl) / 0.12156862745098039) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.12156862745098039) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--app-message-embed-secondary-text: color-mix(in oklab, hsl(var(--white-hsl) / 0.7) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.7) var(--custom-theme-text-color-amount, 0%));
--background-accent: color-mix(in oklab, var(--primary-530) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-base-low: color-mix(in oklab, var(--neutral-66) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-base-lower: color-mix(in oklab, var(--neutral-69) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-base-lowest: color-mix(in oklab, var(--neutral-73) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-code: color-mix(in oklab, hsl(var(--opacity-blurple-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-code-addition: color-mix(in oklab, hsl(var(--opacity-green-12-hsl) / 0.12156862745098039) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.12156862745098039) var(--custom-theme-base-color-amount, 0%));
--background-code-deletion: color-mix(in oklab, hsl(var(--opacity-red-12-hsl) / 0.12156862745098039) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.12156862745098039) var(--custom-theme-base-color-amount, 0%));
--background-feedback-critical: color-mix(in oklab, hsl(var(--opacity-red-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-feedback-info: color-mix(in oklab, hsl(var(--opacity-blue-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-feedback-positive: color-mix(in oklab, hsl(var(--opacity-green-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-feedback-warning: color-mix(in oklab, hsl(var(--opacity-yellow-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-scrim: color-mix(in oklab, hsl(var(--opacity-black-72-hsl) / 0.7215686274509804) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.7215686274509804) var(--custom-theme-base-color-amount, 0%));
--background-scrim-lightbox: color-mix(in oklab, hsl(var(--opacity-black-92-hsl) / 0.9215686274509803) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.9215686274509803) var(--custom-theme-base-color-amount, 0%));
--background-secondary-alt: color-mix(in oklab, var(--primary-660) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-surface-high: color-mix(in oklab, var(--neutral-64) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-surface-higher: color-mix(in oklab, var(--neutral-62) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-surface-highest: color-mix(in oklab, var(--neutral-60) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-tile-gradient-pink-end: color-mix(in oklab, hsl(var(--illo-pink-70-hsl) / 0.3) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.3) var(--custom-theme-base-color-amount, 0%));
--background-tile-gradient-pink-start: color-mix(in oklab, hsl(var(--illo-pink-50-hsl) / 0.3) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.3) var(--custom-theme-base-color-amount, 0%));
--bg-surface-raised: color-mix(in oklab, var(--primary-560) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--border-feedback-critical: color-mix(in oklab, hsl(var(--opacity-red-20-hsl) / 0.2) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.2) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-focus: color-mix(in oklab, var(--blue-new-30) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-muted: color-mix(in oklab, hsl(var(--opacity-4-hsl) / 0.0392156862745098) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0392156862745098) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-normal: color-mix(in oklab, hsl(var(--opacity-20-hsl) / 0.2) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.2) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-strong: color-mix(in oklab, hsl(var(--opacity-44-hsl) / 0.4392156862745098) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.4392156862745098) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-subtle: color-mix(in oklab, hsl(var(--opacity-12-hsl) / 0.12156862745098039) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.12156862745098039) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--card-background-default: color-mix(in oklab, var(--neutral-64) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--card-primary-pressed-bg: color-mix(in oklab, var(--primary-645) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--card-secondary-bg: color-mix(in oklab, hsl(var(--opacity-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--card-secondary-pressed-bg: color-mix(in oklab, var(--primary-645) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--channel-icon: color-mix(in oklab, var(--neutral-28) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--channel-text-area-placeholder: color-mix(in oklab, var(--primary-430) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--channels-default: color-mix(in oklab, var(--neutral-28) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--channeltextarea-background: color-mix(in oklab, var(--primary-560) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--chat-background: color-mix(in oklab, var(--primary-600) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--chat-background-default: color-mix(in oklab, var(--neutral-64) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--chat-border: color-mix(in oklab, var(--primary-700) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--chat-text-muted: color-mix(in oklab, var(--neutral-27) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--content-inventory-media-seekbar-container: color-mix(in oklab, hsl(var(--plum-6-hsl) / 0.24) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.24) var(--custom-theme-base-color-amount, 0%));
--content-inventory-overlay-text-primary: color-mix(in oklab, hsl(var(--white-hsl) / 0.85) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.85) var(--custom-theme-text-color-amount, 0%));
--content-inventory-overlay-text-secondary: color-mix(in oklab, hsl(var(--white-hsl) / 0.7) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.7) var(--custom-theme-text-color-amount, 0%));
--context-menu-backdrop-background: color-mix(in oklab, hsl(var(--opacity-black-72-hsl) / 0.7215686274509804) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.7215686274509804) var(--custom-theme-base-color-amount, 0%));
--control-brand-foreground: color-mix(in oklab, var(--brand-360) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--control-brand-foreground-new: color-mix(in oklab, var(--brand-360) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--control-secondary-border-active: color-mix(in oklab, hsl(var(--opacity-4-hsl) / 0.0392156862745098) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0392156862745098) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--control-secondary-border-default: color-mix(in oklab, hsl(var(--opacity-4-hsl) / 0.0392156862745098) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0392156862745098) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--creator-revenue-icon-gradient-end: color-mix(in oklab, var(--teal-430) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
Show all properties (171 more)
}
}
.theme-dark {
--app-frame-background: var(--neutral-78);
--app-frame-border: hsl(var(--opacity-12-hsl) / 0.12156862745098039);
--app-message-embed-secondary-text: hsl(var(--white-hsl) / 0.7);
--background-accent: var(--primary-530);
--background-base-low: var(--neutral-66);
--background-base-lower: var(--neutral-69);
--background-base-lowest: var(--neutral-73);
--background-brand: var(--blurple-50);
--background-code: hsl(var(--opacity-blurple-8-hsl) / 0.0784313725490196);
--background-code-addition: hsl(var(--opacity-green-12-hsl) / 0.12156862745098039);
--background-code-deletion: hsl(var(--opacity-red-12-hsl) / 0.12156862745098039);
--background-feedback-critical: hsl(var(--opacity-red-8-hsl) / 0.0784313725490196);
--background-feedback-info: hsl(var(--opacity-blue-8-hsl) / 0.0784313725490196);
--background-feedback-notification: var(--red-new-46);
--background-feedback-positive: hsl(var(--opacity-green-8-hsl) / 0.0784313725490196);
--background-feedback-warning: hsl(var(--opacity-yellow-8-hsl) / 0.0784313725490196);
--background-mod-muted: hsl(var(--opacity-4-hsl) / 0.0392156862745098);
--background-mod-normal: hsl(var(--opacity-16-hsl) / 0.1607843137254902);
--background-mod-strong: hsl(var(--opacity-20-hsl) / 0.2);
--background-mod-subtle: hsl(var(--opacity-8-hsl) / 0.0784313725490196);
--background-scrim: hsl(var(--opacity-black-72-hsl) / 0.7215686274509804);
--background-scrim-lightbox: hsl(var(--opacity-black-92-hsl) / 0.9215686274509803);
--background-secondary-alt: var(--primary-660);
--background-surface-high: var(--neutral-64);
--background-surface-higher: var(--neutral-62);
--background-surface-highest: var(--neutral-60);
--background-tile-gradient-pink-end: hsl(var(--illo-pink-70-hsl) / 0.3);
--background-tile-gradient-pink-start: hsl(var(--illo-pink-50-hsl) / 0.3);
--badge-background-brand: var(--blurple-50);
--badge-background-default: hsl(var(--opacity-16-hsl) / 0.1607843137254902);
--badge-expressive-background-default: var(--neutral-1);
--badge-expressive-text-default: var(--neutral-71);
--badge-notification-background: var(--red-new-46);
--badge-text-brand: var(--neutral-1);
--badge-text-default: var(--neutral-1);
--bg-surface-raised: var(--primary-560);
--border-feedback-critical: hsl(var(--opacity-red-20-hsl) / 0.2);
--border-focus: var(--blue-new-30);
--border-muted: hsl(var(--opacity-4-hsl) / 0.0392156862745098);
--border-normal: hsl(var(--opacity-20-hsl) / 0.2);
--border-strong: hsl(var(--opacity-44-hsl) / 0.4392156862745098);
--border-subtle: hsl(var(--opacity-12-hsl) / 0.12156862745098039);
--button-danger-background-disabled: var(--red-new-50);
--button-outline-brand-background-hover: var(--brand-500);
--button-outline-brand-border-active: var(--brand-560);
--button-outline-primary-text: var(--white);
--card-background-default: var(--neutral-64);
--card-primary-pressed-bg: var(--primary-645);
--card-secondary-bg: hsl(var(--opacity-8-hsl) / 0.0784313725490196);
--card-secondary-pressed-bg: var(--primary-645);
Show all properties (396 more)
}
:root {
--application-subscription-end: hsl(var(--application-subscription-end-hsl) / 1);
--application-subscription-end-hsl: 196.564 calc(var(--saturation-factor, 1) * 98.788%) 32.353%;
--application-subscription-start: hsl(var(--application-subscription-start-hsl) / 1);
--application-subscription-start-hsl: 234.909 calc(var(--saturation-factor, 1) * 68.465%) 52.745%;
--battlenet: hsl(var(--battlenet-hsl) / 1);
--battlenet-hsl: 199.651 calc(var(--saturation-factor, 1) * 100%) 44.902%;
--bg-animated-gradient-background-indigo-1: hsl(var(--bg-animated-gradient-background-indigo-1-hsl) / 1);
--bg-animated-gradient-background-indigo-1-hsl: 241.5 calc(var(--saturation-factor, 1) * 57.143%) 27.451%;
--bg-animated-gradient-background-indigo-2: hsl(var(--bg-animated-gradient-background-indigo-2-hsl) / 1);
--bg-animated-gradient-background-indigo-2-hsl: 257.059 calc(var(--saturation-factor, 1) * 100%) 20%;
--bg-animated-gradient-background-not-black: hsl(var(--bg-animated-gradient-background-not-black-hsl) / 1);
--bg-animated-gradient-background-not-black-hsl: 240 calc(var(--saturation-factor, 1) * 7.143%) 5.49%;
--bg-animated-gradient-background-pink-1: hsl(var(--bg-animated-gradient-background-pink-1-hsl) / 1);
--bg-animated-gradient-background-pink-1-hsl: 327.831 calc(var(--saturation-factor, 1) * 80.583%) 59.608%;
--bg-gradient-aurora-1: hsl(var(--bg-gradient-aurora-1-hsl) / 1);
--bg-gradient-aurora-1-hsl: 219.74 calc(var(--saturation-factor, 1) * 86.517%) 17.451%;
--bg-gradient-aurora-2: hsl(var(--bg-gradient-aurora-2-hsl) / 1);
--bg-gradient-aurora-2-hsl: 237.778 calc(var(--saturation-factor, 1) * 76.415%) 41.569%;
--bg-gradient-aurora-3: hsl(var(--bg-gradient-aurora-3-hsl) / 1);
--bg-gradient-aurora-3-hsl: 183.556 calc(var(--saturation-factor, 1) * 78.035%) 33.922%;
--bg-gradient-aurora-4: hsl(var(--bg-gradient-aurora-4-hsl) / 1);
--bg-gradient-aurora-4-hsl: 169.2 calc(var(--saturation-factor, 1) * 60.241%) 32.549%;
--bg-gradient-aurora-5: hsl(var(--bg-gradient-aurora-5-hsl) / 1);
--bg-gradient-aurora-5-hsl: 229.839 calc(var(--saturation-factor, 1) * 92.537%) 26.275%;
--bg-gradient-blurple-twilight-1: hsl(var(--bg-gradient-blurple-twilight-1-hsl) / 1);
--bg-gradient-blurple-twilight-1-hsl: 233.904 calc(var(--saturation-factor, 1) * 79.574%) 53.922%;
--bg-gradient-blurple-twilight-2: hsl(var(--bg-gradient-blurple-twilight-2-hsl) / 1);
--bg-gradient-blurple-twilight-2-hsl: 245.294 calc(var(--saturation-factor, 1) * 63.75%) 31.373%;
--bg-gradient-chroma-glow-1: hsl(var(--bg-gradient-chroma-glow-1-hsl) / 1);
--bg-gradient-chroma-glow-1-hsl: 183.39 calc(var(--saturation-factor, 1) * 86.341%) 40.196%;
--bg-gradient-chroma-glow-2: hsl(var(--bg-gradient-chroma-glow-2-hsl) / 1);
--bg-gradient-chroma-glow-2-hsl: 258.113 calc(var(--saturation-factor, 1) * 89.831%) 46.275%;
--bg-gradient-chroma-glow-3: hsl(var(--bg-gradient-chroma-glow-3-hsl) / 1);
--bg-gradient-chroma-glow-3-hsl: 298.491 calc(var(--saturation-factor, 1) * 90.857%) 34.314%;
--bg-gradient-chroma-glow-4: hsl(var(--bg-gradient-chroma-glow-4-hsl) / 1);
--bg-gradient-chroma-glow-4-hsl: 264.767 calc(var(--saturation-factor, 1) * 100%) 66.275%;
--bg-gradient-chroma-glow-5: hsl(var(--bg-gradient-chroma-glow-5-hsl) / 1);
--bg-gradient-chroma-glow-5-hsl: 206.702 calc(var(--saturation-factor, 1) * 75.494%) 50.392%;
--bg-gradient-citrus-sherbert-1: hsl(var(--bg-gradient-citrus-sherbert-1-hsl) / 1);
--bg-gradient-citrus-sherbert-1-hsl: 39.683 calc(var(--saturation-factor, 1) * 88.732%) 58.235%;
--bg-gradient-citrus-sherbert-2: hsl(var(--bg-gradient-citrus-sherbert-2-hsl) / 1);
--bg-gradient-citrus-sherbert-2-hsl: 18 calc(var(--saturation-factor, 1) * 81.522%) 63.922%;
--bg-gradient-cotton-candy-1: hsl(var(--bg-gradient-cotton-candy-1-hsl) / 1);
--bg-gradient-cotton-candy-1-hsl: 349.315 calc(var(--saturation-factor, 1) * 76.842%) 81.373%;
--bg-gradient-cotton-candy-2: hsl(var(--bg-gradient-cotton-candy-2-hsl) / 1);
--bg-gradient-cotton-candy-2-hsl: 226.4 calc(var(--saturation-factor, 1) * 92.593%) 84.118%;
--bg-gradient-crimson-moon-1: hsl(var(--bg-gradient-crimson-moon-1-hsl) / 1);
--bg-gradient-crimson-moon-1-hsl: 0 calc(var(--saturation-factor, 1) * 88.608%) 30.98%;
--bg-gradient-crimson-moon-2: hsl(var(--bg-gradient-crimson-moon-2-hsl) / 1);
--bg-gradient-crimson-moon-2-hsl: 0 calc(var(--saturation-factor, 1) * 0%) 0%;
Show all properties (526 more)
}
.theme-dark {
--legacy-elevation-low: 0 1px 5px 0 var(--opacity-black-28);
--legacy-elevation-high: 0 2px 10px 0 var(--opacity-black-20);
--legacy-elevation-border: 0 0 0 1px hsl(var(--primary-700-hsl) / 0.6);
}
:root {
--legacy-elevation-low: 0 1px 5px var(--opacity-black-20);
--legacy-elevation-high: 0 2px 10px 0 var(--opacity-black-8);
--legacy-elevation-border: 0 0 0 1px hsl(var(--primary-300-hsl) / 0.3);
}
:root {
--custom-paginator-round-button-size: 28px;
}
:root {
--custom-app-launcher-sticky-header-height: 66px;
--custom-app-launcher-container-border-radius: var(--radius-sm);
}
:root {
--custom-app-launcher-sticky-header-height: 66px;
--custom-app-launcher-container-border-radius: var(--radius-sm);
}
:root {
--custom-channel-members-bg: var(--background-base-lower);
}
:root {
--custom-user-profile-banner-height: 0;
--custom-user-profile-theme-padding: 0;
--custom-user-profile-base-layer-z-index: 0;
--custom-user-profile-bottom-layer-z-index: 1;
--custom-user-profile-middle-layer-z-index: 2;
--custom-user-profile-top-layer-z-index: 3;
--custom-user-profile-hoist-z-index: 4;
--custom-user-profile-toast-z-index: 5;
}
.root, [data-popout-root], :root {
--__spoiler-background-color--hidden: var(--spoiler-hidden-background);
--__spoiler-background-color--hidden--hover: var(--spoiler-hidden-background-hover);
--__spoiler-background-color--revealed: var(--background-mod-subtle);
--__spoiler-text-color--hidden: transparent;
--__spoiler-warning-text-color: var(--primary-200);
--__spoiler-warning-text-color--hover: var(--white);
--__spoiler-warning-background-color: var(--opacity-black-60);
--__spoiler-warning-background-color--hover: var(--opacity-black-88);
--__spoiler-container-box-shadow-color: var(--opacity-black-8);
--__obscured-background-blur-radius: 40px;
--__obscured-background-brightness: 0.55;
}
.theme-dark {
--brightness: calc(1.5 - var(--saturation-factor, 1) * 0.5);
--contrast: var(--saturation-factor, 1);
}
:root {
--expand-structural-duration: 100ms;
--expand-fade-duration: 200ms;
--expand-easing-function: ease-out;
--collapse-structural-duration: 150ms;
--collapse-fade-duration: 150ms;
--collapse-easing-function: ease-in;
}
.appMount__51fd7, body, html {
height: 100%;
width: 100%;
}
a, abbr, acronym, address, applet, big, blockquote, body, caption, cite, code, dd, del, dfn, div, dl, dt, em, fieldset, form, h1, h2, h3, h4, h5, h6, html, iframe, img, ins, kbd, label, legend, li, object, ol, p, pre, q, s, samp, small, span, strike, strong, table, tbody, td, tfoot, th, thead, tr, tt, ul, var {
border: 0;
font-family: inherit;
font-size: 100%;
font-style: inherit;
font-weight: inherit;
margin: 0;
padding: 0;
vertical-align: baseline;
}
[data-popout-root], html {
--brand-05a: hsla(var(--brand-500-hsl) / 0.05);
--brand-10a: hsla(var(--brand-500-hsl) / 0.1);
--brand-15a: hsla(var(--brand-500-hsl) / 0.15);
--brand-20a: hsla(var(--brand-500-hsl) / 0.2);
--brand-25a: hsla(var(--brand-500-hsl) / 0.25);
--brand-30a: hsla(var(--brand-500-hsl) / 0.3);
--brand-35a: hsla(var(--brand-500-hsl) / 0.35);
--brand-40a: hsla(var(--brand-500-hsl) / 0.4);
--brand-45a: hsla(var(--brand-500-hsl) / 0.45);
--brand-50a: hsla(var(--brand-500-hsl) / 0.5);
--brand-55a: hsla(var(--brand-500-hsl) / 0.55);
--brand-60a: hsla(var(--brand-500-hsl) / 0.6);
--brand-65a: hsla(var(--brand-500-hsl) / 0.65);
--brand-70a: hsla(var(--brand-500-hsl) / 0.7);
--brand-75a: hsla(var(--brand-500-hsl) / 0.75);
--brand-80a: hsla(var(--brand-500-hsl) / 0.8);
--brand-85a: hsla(var(--brand-500-hsl) / 0.85);
--brand-90a: hsla(var(--brand-500-hsl) / 0.9);
--brand-95a: hsla(var(--brand-500-hsl) / 0.95);
}
html[Attributes Style] {
-webkit-locale: "en-US";
}
user agent stylesheet
:root {
view-transition-name: root;
}
user agent stylesheet
html {
display: block;
}
<style>
--custom-voice-invite-suggestions-timer-progress {
syntax: "<number>";
inherits: false;
initial-value: 0;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,836 @@
element.style {
font-size: 100%;
--saturation-factor: 1;
dynamic-range-limit: no-limit;
--custom-zoom: 100;
--devtools-sidebar-width: 0px;
}
.mana-toggle-inputs .theme-darker, .mana-toggle-inputs .theme-midnight, .mana-toggle-inputs.theme-darker, .mana-toggle-inputs.theme-midnight {
--checkbox-background-default: hsl(var(--opacity-black-8-hsl) / 0.0784313725490196);
--checkbox-border-default: hsl(var(--opacity-64-hsl) / 0.6392156862745098);
}
.mana-toggle-inputs .theme-dark, .mana-toggle-inputs.theme-dark {
--checkbox-background-default: hsl(var(--opacity-black-8-hsl) / 0.0784313725490196);
--checkbox-border-default: hsl(var(--opacity-64-hsl) / 0.6392156862745098);
}
:root {
--custom-bg-surface-overlay: rgba(33, 34, 41, .8);
}
:root {
--custom-bg-surface-overlay: rgba(33, 34, 41, .8);
}
.theme-dark {
--legacy-elevation-low: 0 1px 5px 0 var(--opacity-black-28);
--legacy-elevation-high: 0 2px 10px 0 var(--opacity-black-20);
--legacy-elevation-border: 0 0 0 1px hsl(var(--primary-700-hsl) / 0.6);
}
:root {
--legacy-elevation-low: 0 1px 5px var(--opacity-black-20);
--legacy-elevation-high: 0 2px 10px 0 var(--opacity-black-8);
--legacy-elevation-border: 0 0 0 1px hsl(var(--primary-300-hsl) / 0.3);
}
:root {
--custom-premium-marketing-hero-heading-padding-top: 120px;
}
:root {
--custom-app-message-embed-base-info-gap: 4px;
--custom-app-message-embed-base-info-top: calc(var(--custom-app-message-embed-base-info-gap) - 2px);
}
:root {
--custom-app-message-embed-base-info-gap: 4px;
--custom-app-message-embed-base-info-top: calc(var(--custom-app-message-embed-base-info-gap) - 2px);
}
:root {
--custom-app-message-embed-base-info-gap: 4px;
--custom-app-message-embed-base-info-top: calc(var(--custom-app-message-embed-base-info-gap) - 2px);
}
:root {
--custom-app-message-embed-base-info-gap: 4px;
--custom-app-message-embed-base-info-top: calc(var(--custom-app-message-embed-base-info-gap) - 2px);
}
:root {
--custom-guild-list-padding: var(--space-md);
--custom-guild-list-width: calc(var(--guildbar-avatar-size) + var(--custom-guild-list-padding) * 2);
--custom-guild-sidebar-width: 268px;
--custom-app-sidebar-target-width: calc(var(--custom-guild-sidebar-width) + var(--custom-guild-list-width));
--custom-rtc-account-height: 44px;
--custom-app-top-bar-height: 32px;
--custom-app-top-bar-item-radius: 6px;
--custom-channel-header-height: calc(var(--guildbar-avatar-size) + var(--space-xs));
--custom-member-list-width: 264px;
--custom-channel-textarea-text-area-height: 56px;
--custom-chat-aligned-icon-offset: ((var(--chat-avatar-size) - var(--chat-input-icon-size)) / 2);
--custom-message-margin-horizontal: var(--space-md);
}
:root {
--custom-add-permissions-modal-focus-ring-width: 4px;
--custom-custom-role-icon-form-item-role-icon-preview-size: 32px;
--custom-guild-settings-roles-edit-shared-sidebar-width: 232px;
--custom-guild-settings-roles-intro-roles-transition: 250ms;
--custom-guild-settings-roles-intro-pause-transition: 166ms;
--custom-guild-settings-roles-intro-background-transition: 500ms;
--custom-guild-settings-roles-intro-banner-transition-delay: calc(var(--custom-guild-settings-roles-intro-roles-transition) + var(--custom-guild-settings-roles-intro-pause-transition));
--custom-guild-settings-roles-intro-roles-transition-delay: calc(var(--custom-guild-settings-roles-intro-roles-transition) + var(--custom-guild-settings-roles-intro-pause-transition) * 2 + var(--custom-guild-settings-roles-intro-background-transition));
--custom-guild-settings-community-intro-content-spacing: 32px;
--custom-guild-settings-community-intro-hover-distance: -12px;
--custom-guild-settings-community-intro-text-spacing: 8px;
--custom-guild-settings-discovery-landing-page-max-width-tab: 905px;
--custom-guild-settings-discovery-landing-page-settings-max-width: 520px;
--custom-guild-settings-partner-content-spacing: 32px;
--custom-event-detail-info-tab-base-spacing: 8px;
--custom-subscription-listing-previews-carousel-cards-get-cut-off-width: 724px;
--custom-editable-benefits-list-emoji-size: 24px;
--custom-edit-benefit-modal-emoji-size: 22px;
--custom-edit-benefit-modal-emoji-margin: 10px;
--custom-guild-settings-role-subscriptions-max-width: 905px;
--custom-guild-settings-role-subscriptions-overview-settings-max-width: 520px;
--custom-guild-settings-store-page-settings-max-width: 520px;
--custom-importable-benefits-list-listing-image-size: 40px;
--custom-import-benefits-modal-icon-size: 24px;
--custom-import-benefits-modal-role-icon-size: 40px;
--custom-role-icon-uploader-icon-size: 24px;
--custom-guild-role-subscription-style-constants-cover-image-aspect-ratio: 4;
--custom-historic-earnings-table-toggle-expand-column-width: 30px;
--custom-guild-role-subscription-card-basic-info-tier-image-size: 80px;
--custom-guild-role-subscription-card-basic-info-tier-image-size-mobile: 48px;
--custom-guild-role-subscriptions-overview-page-page-max-width: 1180px;
--custom-guild-dialog-popout-width: 250px;
--custom-guild-dialog-splash-ratio: 1.77778;
--custom-guild-dialog-icon-size: 84px;
--custom-guild-dialog-icon-padding: 4px;
--custom-guild-product-download-modal-header-image-width: 119px;
--custom-guild-onboarding-home-page-max-page-width: 1128px;
--custom-guild-onboarding-home-page-max-single-column-width: 704px;
--custom-home-resource-channels-obscured-blur-radius: 20px;
--custom-guild-member-application-review-sidebar-width: 29vw;
--custom-featured-items-popout-featured-items-popout-footer-height: 120px;
--custom-guild-boosting-sidebar-display-conditional-bottom-margin: 12px;
--custom-guild-boosting-marketing-progress-bar-marker-dimensions: 32px;
--custom-guild-boosting-marketing-progress-bar-end-markers-margin: 4px;
--custom-guild-boosting-marketing-progress-bar-marker-marker-dimensions: 32px;
--custom-guild-boosting-marketing-tier-cards-tier-card-border-radius: 16px;
--custom-go-live-modal-art-height: 112px;
--custom-gif-picker-gutter-size: 0 16px 12px 16px;
--custom-gif-picker-search-results-desired-item-width: 160px;
--custom-forum-composer-attachments-attachment-size: 78px;
Show all properties (149 more)
}
:root {
--custom-index-scrollbar-width: 10px;
--custom-index-scrollbar-margin: 3px;
--custom-auth-box-auth-box-padding: 32px;
--custom-wave-splash-responsive-width-mobile: 485px;
--custom-wave-splash-responsive-width-mobile-first: 486px;
--custom-wave-splash-responsive-width-desktop: 1080px;
--custom-wave-splash-max-qr-login-width: 830px;
--custom-channel-text-area-button-hover-scale: 0.85714;
--custom-drag-resize-container-handle-size: 8px;
--custom-drag-resize-container-handle-bleed: 2px;
--custom-drag-resize-container-handle-offset: calc(var(--custom-drag-resize-container-handle-bleed) - var(--custom-drag-resize-container-handle-size));
--custom-embed-spoiler-blur-radius: 44px;
--custom-gradient-progress-notch-width: 8px;
--custom-gradient-progress-notch-height: 16px;
--custom-gradient-progress-notch-margin: 2px;
--custom-guild-discovery-card-card-height: 320px;
--custom-guild-discovery-card-card-height-with-tags: 350px;
--custom-icon-button-icon-lg-size: 36px;
--custom-icon-button-icon-md-size: 24px;
--custom-icon-button-icon-sm-size: 18px;
--custom-icon-button-icon-xs-size: 12px;
--custom-invite-button-resolving-background-width: 380px;
--custom-keybind-space-around-key: 8px;
--custom-keybind-shadow-width: 2px;
--custom-keybind-vertical-padding-total-height: 8px;
--custom-keybind-applied-vertical-padding: calc((var(--custom-keybind-vertical-padding-total-height) - var(--custom-keybind-shadow-width)) / 2);
--custom-full-screen-layer-animation-duration: 150ms;
--custom-layout-sidebar-width: 232px;
--custom-message-avatar-size: 40px;
--custom-message-avatar-decoration-size: calc(var(--custom-message-avatar-size) * var(--decoration-to-avatar-ratio));
--custom-message-margin-compact-indent: 5rem;
--custom-message-spacing-vertical-container-cozy: 0.125rem;
--custom-message-padding-vertical-container-compact: 0.125rem;
--custom-message-meta-space: 0.25rem;
--custom-message-reply-indent: 0.625rem;
--custom-message-margin-left-content-cozy: calc(var(--custom-message-avatar-size, 40px) + var(--custom-message-margin-horizontal) + var(--custom-message-margin-horizontal));
--custom-message-reply-message-preview-line-height: 1.125rem;
--custom-message-attachment-spoiler-blur-radius: 44px;
--custom-user-premium-guild-subscription-easter-egg-size: 196px;
--custom-notification-spacing: 12px;
--custom-notification-container-width: 300px;
--custom-notification-space-around-divider: 12px;
--custom-notification-box-shadow-opacity: 0.8;
--custom-notification-box-shadow-blur-radius: 7px;
--custom-notification-box-shadow-spread-radius: 3px;
--custom-widget-max-widget-height: 100vh;
--custom-widget-bar-padding: 12px;
--custom-widget-body-padding: 4px;
--custom-widget-bar-height: 20px;
--custom-premium-guild-progress-bar-progress-bar-width: 24px;
Show all properties (113 more)
}
.density-default {
--channels-name-line-height: 24px;
--channels-spine-inverted-offset-top: 6px;
--channels-spine-offset-left: 24px;
}
:root {
--font-weight-light: 300;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--font-weight-extra-bold: 800;
--channels-name-line-height: 24px;
--channels-spine-inverted-offset-top: 6px;
--channels-spine-offset-left: 24px;
--chat-avatar-size: 40px;
--chat-input-icon-size: 20px;
--chat-markup-line-height: 1.375rem;
--chat-resize-handle-width: 8px;
--control-input-height-md: 40px;
--control-input-height-sm: 32px;
--control-item-height-md: 40px;
--control-item-height-sm: 32px;
--form-input-height: 44px;
--guildbar-avatar-size: 40px;
--guildbar-folder-size: 48px;
--icon-size-lg: 32px;
--icon-size-md: 24px;
--icon-size-sm: 18px;
--icon-size-xs: 16px;
--icon-size-xxs: 12px;
--modal-horizontal-padding: 24px;
--modal-vertical-padding: 16px;
--modal-width-large: 800px;
--modal-width-medium: 602px;
--modal-width-small: 442px;
--select-max-width: 248px;
--select-option-height: 40px;
}
:root {
--font-primary: "gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-display: "gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-headline: "ABC Ginto Nord", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-code: "gg mono", "Source Code Pro", Consolas, "Andale Mono WT", "Andale Mono", "Lucida Console", "Lucida Sans Typewriter", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", "Liberation Mono", "Nimbus Mono L", Monaco, "Courier New", Courier, monospace;
--font-clan-body: Fraunces, "gg sans", serif, "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-clan-signature: Corinthia, "gg sans", cursive, "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-display-marketing: "ABC Ginto Discord", "gg sans", serif, "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-display-marketing-header: "ABC Ginto Nord Discord", "gg sans", serif, "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
.theme-dark {
--guild-header-text-shadow: 0 1px 1px hsl(var(--black-hsl) / 0.4);
--elevation-stroke: 0 0 0 1px hsl(var(--primary-900-hsl) / 0.15);
--elevation-low: 0 1px 0 hsl(var(--primary-900-hsl) / 0.2), 0 1.5px 0 hsl(var(--primary-860-hsl) / 0.05), 0 2px 0 hsl(var(--primary-900-hsl) / 0.05);
--elevation-medium: 0 4px 4px hsl(var(--black-hsl) / 0.16);
--elevation-high: 0 8px 16px hsl(var(--black-hsl) / 0.24);
}
:root {
--radius-none: 0px;
--radius-xs: 4px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
--radius-xxl: 32px;
--radius-round: 2147483647px;
}
.density-default {
--space-xxs: var(--space-4);
--space-xs: var(--space-8);
--space-sm: var(--space-12);
--space-md: var(--space-16);
--space-lg: var(--space-20);
--space-xl: var(--space-24);
--space-xxl: var(--space-32);
}
:root {
--size-0: 0px;
--size-4: 4px;
--size-8: 8px;
--size-12: 12px;
--size-16: 16px;
--size-20: 20px;
--size-24: 24px;
--size-32: 32px;
--size-48: 48px;
--size-64: 64px;
--size-80: 80px;
--size-96: 96px;
--size-128: 128px;
--size-160: 160px;
--size-192: 192px;
--size-xxs: var(--size-4);
--size-xs: var(--size-8);
--size-sm: var(--size-12);
--size-md: var(--size-16);
--size-lg: var(--size-20);
--size-xl: var(--size-24);
--size-xxl: var(--size-32);
--breakpoint-480: 480px;
--breakpoint-640: 640px;
--breakpoint-768: 768px;
--breakpoint-1024: 1024px;
--breakpoint-1280: 1280px;
--breakpoint-1536: 1536px;
--breakpoint-1800: 1800px;
--breakpoint-2500: 2500px;
--breakpoint-xxs: 480px;
--breakpoint-xs: 640px;
--breakpoint-sm: 768px;
--breakpoint-md: 1024px;
--breakpoint-lg: 1280px;
--breakpoint-xl: 1536px;
--breakpoint-xxl: 1800px;
--breakpoint-max: 2500px;
--space-0: 0px;
--space-4: 4px;
--space-6: 6px;
--space-8: 8px;
--space-10: 10px;
--space-12: 12px;
--space-16: 16px;
--space-20: 20px;
--space-24: 24px;
--space-26: 26px;
--space-30: 30px;
--space-32: 32px;
Show all properties (15 more)
}
.theme-darker, .theme-midnight {
--shadow-border: 0 0 0 1px hsl(none 0% 100% / 0.08);
--shadow-border-filter: drop-shadow(0 0 1px hsl(none 0% 100% / 0.08));
--shadow-button-overlay: 0 12px 24px 0 hsl(none 0% 0% / 0.24);
--shadow-button-overlay-filter: drop-shadow(0 12px 24px hsl(none 0% 0% / 0.24));
--shadow-high: 0 12px 24px 0 hsl(none 0% 0% / 0.24);
--shadow-high-filter: drop-shadow(0 12px 24px hsl(none 0% 0% / 0.24));
--shadow-ledge: 0 2px 0 0 hsl(none 0% 0% / 0.05), 0 1.5px 0 0 hsl(none 0% 0% / 0.05), 0 1px 0 0 hsl(none 0% 0% / 0.16);
--shadow-ledge-filter: drop-shadow(0 1.5px 0 hsl(none 0% 0% / 0.24));
--shadow-low: 0 1px 4px 0 hsl(none 0% 0% / 0.14);
--shadow-low-filter: drop-shadow(0 1px 4px hsl(none 0% 0% / 0.14));
--shadow-low-active: 0 0 4px 0 hsl(none 0% 0% / 0.14);
--shadow-low-active-filter: drop-shadow(0 0 4px hsl(none 0% 0% / 0.14));
--shadow-low-hover: 0 4px 10px 0 hsl(none 0% 0% / 0.14);
--shadow-low-hover-filter: drop-shadow(0 4px 10px hsl(none 0% 0% / 0.14));
--shadow-medium: 0 4px 8px 0 hsl(none 0% 0% / 0.16);
--shadow-medium-filter: drop-shadow(0 4px 8px hsl(none 0% 0% / 0.16));
--shadow-mobile-navigator-x: 0 0 10px 0 hsl(none 0% 0% / 0.22);
--shadow-mobile-navigator-x-filter: drop-shadow(0 0 10px hsl(none 0% 0% / 0.22));
--shadow-top-high: 0 -12px 32px 0 hsl(none 0% 0% / 0.24);
--shadow-top-high-filter: drop-shadow(0 -12px 32px hsl(none 0% 0% / 0.24));
--shadow-top-ledge: 0 -2px 0 0 hsl(none 0% 0% / 0.05), 0 -1.5px 0 0 hsl(none 0% 0% / 0.05), 0 -1px 0 0 hsl(none 0% 0% / 0.16);
--shadow-top-ledge-filter: drop-shadow(0 -1.5px 0 hsl(none 0% 0% / 0.24));
--shadow-top-low: 0 -1px 4px 0 hsl(none 0% 0% / 0.14);
--shadow-top-low-filter: drop-shadow(0 -1px 4px hsl(none 0% 0% / 0.14));
}
.theme-dark {
--shadow-border: 0 0 0 1px hsl(none 0% 100% / 0.08);
--shadow-border-filter: drop-shadow(0 0 1px hsl(none 0% 100% / 0.08));
--shadow-button-overlay: 0 12px 24px 0 hsl(none 0% 0% / 0.24);
--shadow-button-overlay-filter: drop-shadow(0 12px 24px hsl(none 0% 0% / 0.24));
--shadow-high: 0 12px 24px 0 hsl(none 0% 0% / 0.24);
--shadow-high-filter: drop-shadow(0 12px 24px hsl(none 0% 0% / 0.24));
--shadow-ledge: 0 2px 0 0 hsl(none 0% 0% / 0.05), 0 1.5px 0 0 hsl(none 0% 0% / 0.05), 0 1px 0 0 hsl(none 0% 0% / 0.16);
--shadow-ledge-filter: drop-shadow(0 1.5px 0 hsl(none 0% 0% / 0.24));
--shadow-low: 0 1px 4px 0 hsl(none 0% 0% / 0.14);
--shadow-low-filter: drop-shadow(0 1px 4px hsl(none 0% 0% / 0.14));
--shadow-low-active: 0 0 4px 0 hsl(none 0% 0% / 0.14);
--shadow-low-active-filter: drop-shadow(0 0 4px hsl(none 0% 0% / 0.14));
--shadow-low-hover: 0 4px 10px 0 hsl(none 0% 0% / 0.14);
--shadow-low-hover-filter: drop-shadow(0 4px 10px hsl(none 0% 0% / 0.14));
--shadow-medium: 0 4px 8px 0 hsl(none 0% 0% / 0.16);
--shadow-medium-filter: drop-shadow(0 4px 8px hsl(none 0% 0% / 0.16));
--shadow-mobile-navigator-x: 0 0 10px 0 hsl(none 0% 0% / 0.22);
--shadow-mobile-navigator-x-filter: drop-shadow(0 0 10px hsl(none 0% 0% / 0.22));
--shadow-top-high: 0 -12px 32px 0 hsl(none 0% 0% / 0.24);
--shadow-top-high-filter: drop-shadow(0 -12px 32px hsl(none 0% 0% / 0.24));
--shadow-top-ledge: 0 -2px 0 0 hsl(none 0% 0% / 0.05), 0 -1.5px 0 0 hsl(none 0% 0% / 0.05), 0 -1px 0 0 hsl(none 0% 0% / 0.16);
--shadow-top-ledge-filter: drop-shadow(0 -1.5px 0 hsl(none 0% 0% / 0.24));
--shadow-top-low: 0 -1px 4px 0 hsl(none 0% 0% / 0.14);
--shadow-top-low-filter: drop-shadow(0 -1px 4px hsl(none 0% 0% / 0.14));
}
.visual-refresh {
--blue-100: var(--blue-new-1);
--blue-100-hsl: var(--blue-new-1-hsl);
--blue-130: var(--blue-new-1);
--blue-130-hsl: var(--blue-new-1-hsl);
--blue-160: var(--blue-new-1);
--blue-160-hsl: var(--blue-new-1-hsl);
--blue-200: var(--blue-new-5);
--blue-200-hsl: var(--blue-new-5-hsl);
--blue-230: var(--blue-new-11);
--blue-230-hsl: var(--blue-new-11-hsl);
--blue-260: var(--blue-new-16);
--blue-260-hsl: var(--blue-new-16-hsl);
--blue-300: var(--blue-new-24);
--blue-300-hsl: var(--blue-new-24-hsl);
--blue-330: var(--blue-new-30);
--blue-330-hsl: var(--blue-new-30-hsl);
--blue-345: var(--blue-new-36);
--blue-345-hsl: var(--blue-new-36-hsl);
--blue-360: var(--blue-new-40);
--blue-360-hsl: var(--blue-new-40-hsl);
--blue-400: var(--blue-new-46);
--blue-400-hsl: var(--blue-new-46-hsl);
--blue-430: var(--blue-new-52);
--blue-430-hsl: var(--blue-new-52-hsl);
--blue-460: var(--blue-new-57);
--blue-460-hsl: var(--blue-new-57-hsl);
--blue-500: var(--blue-new-62);
--blue-500-hsl: var(--blue-new-62-hsl);
--blue-530: var(--blue-new-67);
--blue-530-hsl: var(--blue-new-67-hsl);
--blue-560: var(--blue-new-71);
--blue-560-hsl: var(--blue-new-71-hsl);
--blue-600: var(--blue-new-75);
--blue-600-hsl: var(--blue-new-75-hsl);
--blue-630: var(--blue-new-78);
--blue-630-hsl: var(--blue-new-78-hsl);
--blue-660: var(--blue-new-81);
--blue-660-hsl: var(--blue-new-81-hsl);
--blue-700: var(--blue-new-84);
--blue-700-hsl: var(--blue-new-84-hsl);
--blue-730: var(--blue-new-87);
--blue-730-hsl: var(--blue-new-87-hsl);
--blue-760: var(--blue-new-90);
--blue-760-hsl: var(--blue-new-90-hsl);
--blue-800: var(--blue-new-92);
--blue-800-hsl: var(--blue-new-92-hsl);
--blue-830: var(--blue-new-94);
--blue-830-hsl: var(--blue-new-94-hsl);
--blue-860: var(--blue-new-95);
--blue-860-hsl: var(--blue-new-95-hsl);
Show all properties (422 more)
}
:root {
--neutral-1: hsl(var(--neutral-1-hsl) / 1);
--neutral-1-hsl: 0 calc(var(--saturation-factor, 1) * 0%) 100%;
--neutral-2: hsl(var(--neutral-2-hsl) / 1);
--neutral-2-hsl: 0 calc(var(--saturation-factor, 1) * 0%) 98.431%;
--neutral-3: hsl(var(--neutral-3-hsl) / 1);
--neutral-3-hsl: 240 calc(var(--saturation-factor, 1) * 6.667%) 97.059%;
--neutral-4: hsl(var(--neutral-4-hsl) / 1);
--neutral-4-hsl: 240 calc(var(--saturation-factor, 1) * 4.348%) 95.49%;
--neutral-5: hsl(var(--neutral-5-hsl) / 1);
--neutral-5-hsl: 240 calc(var(--saturation-factor, 1) * 6.667%) 94.118%;
--neutral-6: hsl(var(--neutral-6-hsl) / 1);
--neutral-6-hsl: 210 calc(var(--saturation-factor, 1) * 5.263%) 92.549%;
--neutral-7: hsl(var(--neutral-7-hsl) / 1);
--neutral-7-hsl: 240 calc(var(--saturation-factor, 1) * 4.545%) 91.373%;
--neutral-8: hsl(var(--neutral-8-hsl) / 1);
--neutral-8-hsl: 240 calc(var(--saturation-factor, 1) * 3.846%) 89.804%;
--neutral-9: hsl(var(--neutral-9-hsl) / 1);
--neutral-9-hsl: 240 calc(var(--saturation-factor, 1) * 5.085%) 88.431%;
--neutral-10: hsl(var(--neutral-10-hsl) / 1);
--neutral-10-hsl: 240 calc(var(--saturation-factor, 1) * 4.478%) 86.863%;
--neutral-11: hsl(var(--neutral-11-hsl) / 1);
--neutral-11-hsl: 225 calc(var(--saturation-factor, 1) * 5.405%) 85.49%;
--neutral-12: hsl(var(--neutral-12-hsl) / 1);
--neutral-12-hsl: 225 calc(var(--saturation-factor, 1) * 4.878%) 83.922%;
--neutral-13: hsl(var(--neutral-13-hsl) / 1);
--neutral-13-hsl: 240 calc(var(--saturation-factor, 1) * 4.545%) 82.745%;
--neutral-14: hsl(var(--neutral-14-hsl) / 1);
--neutral-14-hsl: 240 calc(var(--saturation-factor, 1) * 4.167%) 81.176%;
--neutral-15: hsl(var(--neutral-15-hsl) / 1);
--neutral-15-hsl: 228 calc(var(--saturation-factor, 1) * 4.854%) 79.804%;
--neutral-16: hsl(var(--neutral-16-hsl) / 1);
--neutral-16-hsl: 228 calc(var(--saturation-factor, 1) * 4.505%) 78.235%;
--neutral-17: hsl(var(--neutral-17-hsl) / 1);
--neutral-17-hsl: 240 calc(var(--saturation-factor, 1) * 4.274%) 77.059%;
--neutral-18: hsl(var(--neutral-18-hsl) / 1);
--neutral-18-hsl: 240 calc(var(--saturation-factor, 1) * 4%) 75.49%;
--neutral-19: hsl(var(--neutral-19-hsl) / 1);
--neutral-19-hsl: 230 calc(var(--saturation-factor, 1) * 4.545%) 74.118%;
--neutral-20: hsl(var(--neutral-20-hsl) / 1);
--neutral-20-hsl: 230 calc(var(--saturation-factor, 1) * 4.286%) 72.549%;
--neutral-21: hsl(var(--neutral-21-hsl) / 1);
--neutral-21-hsl: 240 calc(var(--saturation-factor, 1) * 4.11%) 71.373%;
--neutral-22: hsl(var(--neutral-22-hsl) / 1);
--neutral-22-hsl: 231.429 calc(var(--saturation-factor, 1) * 4.575%) 70%;
--neutral-23: hsl(var(--neutral-23-hsl) / 1);
--neutral-23-hsl: 231.429 calc(var(--saturation-factor, 1) * 4.348%) 68.431%;
--neutral-24: hsl(var(--neutral-24-hsl) / 1);
--neutral-24-hsl: 240 calc(var(--saturation-factor, 1) * 4.192%) 67.255%;
--neutral-25: hsl(var(--neutral-25-hsl) / 1);
--neutral-25-hsl: 231.429 calc(var(--saturation-factor, 1) * 4%) 65.686%;
Show all properties (2780 more)
}
@supports (color:color-mix(in lch,red,blue)) {
.theme-darker {
--app-frame-background: color-mix(in oklab, var(--neutral-97) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--app-frame-border: color-mix(in oklab, hsl(var(--opacity-12-hsl) / 0.12156862745098039) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.12156862745098039) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--app-message-embed-secondary-text: color-mix(in oklab, hsl(var(--white-hsl) / 0.7) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.7) var(--custom-theme-text-color-amount, 0%));
--background-accent: color-mix(in oklab, var(--plum-15) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-base-low: color-mix(in oklab, var(--neutral-82) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-base-lower: color-mix(in oklab, var(--neutral-86) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-base-lowest: color-mix(in oklab, var(--neutral-92) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-code: color-mix(in oklab, hsl(var(--opacity-blurple-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-code-addition: color-mix(in oklab, hsl(var(--opacity-green-12-hsl) / 0.12156862745098039) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.12156862745098039) var(--custom-theme-base-color-amount, 0%));
--background-code-deletion: color-mix(in oklab, hsl(var(--opacity-red-12-hsl) / 0.12156862745098039) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.12156862745098039) var(--custom-theme-base-color-amount, 0%));
--background-feedback-critical: color-mix(in oklab, hsl(var(--opacity-red-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-feedback-info: color-mix(in oklab, hsl(var(--opacity-blue-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-feedback-positive: color-mix(in oklab, hsl(var(--opacity-green-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-feedback-warning: color-mix(in oklab, hsl(var(--opacity-yellow-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-scrim: color-mix(in oklab, hsl(var(--opacity-black-72-hsl) / 0.7215686274509804) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.7215686274509804) var(--custom-theme-base-color-amount, 0%));
--background-scrim-lightbox: color-mix(in oklab, hsl(var(--opacity-black-92-hsl) / 0.9215686274509803) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.9215686274509803) var(--custom-theme-base-color-amount, 0%));
--background-secondary-alt: color-mix(in oklab, var(--plum-15) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-surface-high: color-mix(in oklab, var(--neutral-79) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-surface-higher: color-mix(in oklab, var(--neutral-76) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-surface-highest: color-mix(in oklab, var(--neutral-73) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-tile-gradient-pink-end: color-mix(in oklab, hsl(var(--illo-pink-70-hsl) / 0.3) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.3) var(--custom-theme-base-color-amount, 0%));
--background-tile-gradient-pink-start: color-mix(in oklab, hsl(var(--illo-pink-50-hsl) / 0.3) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.3) var(--custom-theme-base-color-amount, 0%));
--bg-surface-raised: color-mix(in oklab, var(--plum-18) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--border-feedback-critical: color-mix(in oklab, hsl(var(--opacity-red-20-hsl) / 0.2) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.2) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-focus: color-mix(in oklab, var(--blue-new-30) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-muted: color-mix(in oklab, hsl(var(--opacity-4-hsl) / 0.0392156862745098) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0392156862745098) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-normal: color-mix(in oklab, hsl(var(--opacity-20-hsl) / 0.2) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.2) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-strong: color-mix(in oklab, hsl(var(--opacity-44-hsl) / 0.4392156862745098) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.4392156862745098) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-subtle: color-mix(in oklab, hsl(var(--opacity-12-hsl) / 0.12156862745098039) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.12156862745098039) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--card-background-default: color-mix(in oklab, var(--neutral-79) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--card-primary-pressed-bg: color-mix(in oklab, var(--plum-19) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--card-secondary-bg: color-mix(in oklab, hsl(var(--opacity-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--card-secondary-pressed-bg: color-mix(in oklab, var(--plum-21) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--channel-icon: color-mix(in oklab, var(--neutral-35) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--channel-text-area-placeholder: color-mix(in oklab, var(--plum-11) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--channels-default: color-mix(in oklab, var(--neutral-35) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--channeltextarea-background: color-mix(in oklab, var(--plum-15) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--chat-background: color-mix(in oklab, var(--plum-16) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--chat-background-default: color-mix(in oklab, var(--neutral-80) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--chat-border: color-mix(in oklab, var(--plum-20) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--chat-text-muted: color-mix(in oklab, var(--neutral-35) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--content-inventory-media-seekbar-container: color-mix(in oklab, hsl(var(--plum-6-hsl) / 0.24) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.24) var(--custom-theme-base-color-amount, 0%));
--content-inventory-overlay-text-primary: color-mix(in oklab, hsl(var(--white-hsl) / 0.85) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.85) var(--custom-theme-text-color-amount, 0%));
--content-inventory-overlay-text-secondary: color-mix(in oklab, hsl(var(--white-hsl) / 0.7) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.7) var(--custom-theme-text-color-amount, 0%));
--context-menu-backdrop-background: color-mix(in oklab, hsl(var(--opacity-black-72-hsl) / 0.7215686274509804) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.7215686274509804) var(--custom-theme-base-color-amount, 0%));
--control-brand-foreground: color-mix(in oklab, var(--brand-360) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--control-brand-foreground-new: color-mix(in oklab, var(--brand-360) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--control-secondary-border-active: color-mix(in oklab, hsl(var(--opacity-4-hsl) / 0.0392156862745098) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0392156862745098) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--control-secondary-border-default: color-mix(in oklab, hsl(var(--opacity-4-hsl) / 0.0392156862745098) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0392156862745098) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--creator-revenue-icon-gradient-end: color-mix(in oklab, var(--teal-430) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
Show all properties (171 more)
}
}
.theme-darker {
--app-frame-background: var(--neutral-97);
--app-frame-border: hsl(var(--opacity-12-hsl) / 0.12156862745098039);
--app-message-embed-secondary-text: hsl(var(--white-hsl) / 0.7);
--background-accent: var(--plum-15);
--background-base-low: var(--neutral-82);
--background-base-lower: var(--neutral-86);
--background-base-lowest: var(--neutral-92);
--background-brand: var(--blurple-50);
--background-code: hsl(var(--opacity-blurple-8-hsl) / 0.0784313725490196);
--background-code-addition: hsl(var(--opacity-green-12-hsl) / 0.12156862745098039);
--background-code-deletion: hsl(var(--opacity-red-12-hsl) / 0.12156862745098039);
--background-feedback-critical: hsl(var(--opacity-red-8-hsl) / 0.0784313725490196);
--background-feedback-info: hsl(var(--opacity-blue-8-hsl) / 0.0784313725490196);
--background-feedback-notification: var(--red-new-46);
--background-feedback-positive: hsl(var(--opacity-green-8-hsl) / 0.0784313725490196);
--background-feedback-warning: hsl(var(--opacity-yellow-8-hsl) / 0.0784313725490196);
--background-mod-muted: hsl(var(--opacity-4-hsl) / 0.0392156862745098);
--background-mod-normal: hsl(var(--opacity-16-hsl) / 0.1607843137254902);
--background-mod-strong: hsl(var(--opacity-20-hsl) / 0.2);
--background-mod-subtle: hsl(var(--opacity-8-hsl) / 0.0784313725490196);
--background-scrim: hsl(var(--opacity-black-72-hsl) / 0.7215686274509804);
--background-scrim-lightbox: hsl(var(--opacity-black-92-hsl) / 0.9215686274509803);
--background-secondary-alt: var(--plum-15);
--background-surface-high: var(--neutral-79);
--background-surface-higher: var(--neutral-76);
--background-surface-highest: var(--neutral-73);
--background-tile-gradient-pink-end: hsl(var(--illo-pink-70-hsl) / 0.3);
--background-tile-gradient-pink-start: hsl(var(--illo-pink-50-hsl) / 0.3);
--badge-background-brand: var(--blurple-50);
--badge-background-default: hsl(var(--opacity-16-hsl) / 0.1607843137254902);
--badge-expressive-background-default: var(--neutral-1);
--badge-expressive-text-default: var(--neutral-71);
--badge-notification-background: var(--red-new-46);
--badge-text-brand: var(--neutral-1);
--badge-text-default: var(--neutral-2);
--bg-surface-raised: var(--plum-18);
--border-feedback-critical: hsl(var(--opacity-red-20-hsl) / 0.2);
--border-focus: var(--blue-new-30);
--border-muted: hsl(var(--opacity-4-hsl) / 0.0392156862745098);
--border-normal: hsl(var(--opacity-20-hsl) / 0.2);
--border-strong: hsl(var(--opacity-44-hsl) / 0.4392156862745098);
--border-subtle: hsl(var(--opacity-12-hsl) / 0.12156862745098039);
--button-danger-background-disabled: var(--red-new-50);
--button-outline-brand-background-hover: var(--brand-500);
--button-outline-brand-border-active: var(--brand-560);
--button-outline-primary-text: var(--white);
--card-background-default: var(--neutral-79);
--card-primary-pressed-bg: var(--plum-19);
--card-secondary-bg: hsl(var(--opacity-8-hsl) / 0.0784313725490196);
--card-secondary-pressed-bg: var(--plum-21);
Show all properties (396 more)
}
@supports (color:color-mix(in lch,red,blue)) {
.theme-dark {
--app-frame-background: color-mix(in oklab, var(--neutral-78) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--app-frame-border: color-mix(in oklab, hsl(var(--opacity-12-hsl) / 0.12156862745098039) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.12156862745098039) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--app-message-embed-secondary-text: color-mix(in oklab, hsl(var(--white-hsl) / 0.7) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.7) var(--custom-theme-text-color-amount, 0%));
--background-accent: color-mix(in oklab, var(--primary-530) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-base-low: color-mix(in oklab, var(--neutral-66) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-base-lower: color-mix(in oklab, var(--neutral-69) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-base-lowest: color-mix(in oklab, var(--neutral-73) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-code: color-mix(in oklab, hsl(var(--opacity-blurple-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-code-addition: color-mix(in oklab, hsl(var(--opacity-green-12-hsl) / 0.12156862745098039) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.12156862745098039) var(--custom-theme-base-color-amount, 0%));
--background-code-deletion: color-mix(in oklab, hsl(var(--opacity-red-12-hsl) / 0.12156862745098039) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.12156862745098039) var(--custom-theme-base-color-amount, 0%));
--background-feedback-critical: color-mix(in oklab, hsl(var(--opacity-red-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-feedback-info: color-mix(in oklab, hsl(var(--opacity-blue-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-feedback-positive: color-mix(in oklab, hsl(var(--opacity-green-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-feedback-warning: color-mix(in oklab, hsl(var(--opacity-yellow-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--background-scrim: color-mix(in oklab, hsl(var(--opacity-black-72-hsl) / 0.7215686274509804) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.7215686274509804) var(--custom-theme-base-color-amount, 0%));
--background-scrim-lightbox: color-mix(in oklab, hsl(var(--opacity-black-92-hsl) / 0.9215686274509803) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.9215686274509803) var(--custom-theme-base-color-amount, 0%));
--background-secondary-alt: color-mix(in oklab, var(--primary-660) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-surface-high: color-mix(in oklab, var(--neutral-64) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-surface-higher: color-mix(in oklab, var(--neutral-62) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-surface-highest: color-mix(in oklab, var(--neutral-60) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--background-tile-gradient-pink-end: color-mix(in oklab, hsl(var(--illo-pink-70-hsl) / 0.3) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.3) var(--custom-theme-base-color-amount, 0%));
--background-tile-gradient-pink-start: color-mix(in oklab, hsl(var(--illo-pink-50-hsl) / 0.3) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.3) var(--custom-theme-base-color-amount, 0%));
--bg-surface-raised: color-mix(in oklab, var(--primary-560) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--border-feedback-critical: color-mix(in oklab, hsl(var(--opacity-red-20-hsl) / 0.2) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.2) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-focus: color-mix(in oklab, var(--blue-new-30) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-muted: color-mix(in oklab, hsl(var(--opacity-4-hsl) / 0.0392156862745098) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0392156862745098) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-normal: color-mix(in oklab, hsl(var(--opacity-20-hsl) / 0.2) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.2) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-strong: color-mix(in oklab, hsl(var(--opacity-44-hsl) / 0.4392156862745098) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.4392156862745098) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--border-subtle: color-mix(in oklab, hsl(var(--opacity-12-hsl) / 0.12156862745098039) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.12156862745098039) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--card-background-default: color-mix(in oklab, var(--neutral-64) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--card-primary-pressed-bg: color-mix(in oklab, var(--primary-645) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--card-secondary-bg: color-mix(in oklab, hsl(var(--opacity-8-hsl) / 0.0784313725490196) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0784313725490196) var(--custom-theme-base-color-amount, 0%));
--card-secondary-pressed-bg: color-mix(in oklab, var(--primary-645) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--channel-icon: color-mix(in oklab, var(--neutral-28) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--channel-text-area-placeholder: color-mix(in oklab, var(--primary-430) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--channels-default: color-mix(in oklab, var(--neutral-28) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--channeltextarea-background: color-mix(in oklab, var(--primary-560) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--chat-background: color-mix(in oklab, var(--primary-600) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--chat-background-default: color-mix(in oklab, var(--neutral-64) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-base-color-amount, 0%));
--chat-border: color-mix(in oklab, var(--primary-700) 100%, var(--custom-theme-base-color, #000) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--chat-text-muted: color-mix(in oklab, var(--neutral-27) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--content-inventory-media-seekbar-container: color-mix(in oklab, hsl(var(--plum-6-hsl) / 0.24) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.24) var(--custom-theme-base-color-amount, 0%));
--content-inventory-overlay-text-primary: color-mix(in oklab, hsl(var(--white-hsl) / 0.85) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.85) var(--custom-theme-text-color-amount, 0%));
--content-inventory-overlay-text-secondary: color-mix(in oklab, hsl(var(--white-hsl) / 0.7) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.7) var(--custom-theme-text-color-amount, 0%));
--context-menu-backdrop-background: color-mix(in oklab, hsl(var(--opacity-black-72-hsl) / 0.7215686274509804) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.7215686274509804) var(--custom-theme-base-color-amount, 0%));
--control-brand-foreground: color-mix(in oklab, var(--brand-360) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--control-brand-foreground-new: color-mix(in oklab, var(--brand-360) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
--control-secondary-border-active: color-mix(in oklab, hsl(var(--opacity-4-hsl) / 0.0392156862745098) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0392156862745098) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--control-secondary-border-default: color-mix(in oklab, hsl(var(--opacity-4-hsl) / 0.0392156862745098) 100%, hsl(var(--custom-theme-base-color-hsl, 0 0% 0%) / 0.0392156862745098) var(--custom-theme-border-color-amount, var(--custom-theme-base-color-amount, 0%)));
--creator-revenue-icon-gradient-end: color-mix(in oklab, var(--teal-430) 100%, var(--custom-theme-text-color, #000) var(--custom-theme-text-color-amount, 0%));
Show all properties (171 more)
}
}
.theme-dark {
--app-frame-background: var(--neutral-78);
--app-frame-border: hsl(var(--opacity-12-hsl) / 0.12156862745098039);
--app-message-embed-secondary-text: hsl(var(--white-hsl) / 0.7);
--background-accent: var(--primary-530);
--background-base-low: var(--neutral-66);
--background-base-lower: var(--neutral-69);
--background-base-lowest: var(--neutral-73);
--background-brand: var(--blurple-50);
--background-code: hsl(var(--opacity-blurple-8-hsl) / 0.0784313725490196);
--background-code-addition: hsl(var(--opacity-green-12-hsl) / 0.12156862745098039);
--background-code-deletion: hsl(var(--opacity-red-12-hsl) / 0.12156862745098039);
--background-feedback-critical: hsl(var(--opacity-red-8-hsl) / 0.0784313725490196);
--background-feedback-info: hsl(var(--opacity-blue-8-hsl) / 0.0784313725490196);
--background-feedback-notification: var(--red-new-46);
--background-feedback-positive: hsl(var(--opacity-green-8-hsl) / 0.0784313725490196);
--background-feedback-warning: hsl(var(--opacity-yellow-8-hsl) / 0.0784313725490196);
--background-mod-muted: hsl(var(--opacity-4-hsl) / 0.0392156862745098);
--background-mod-normal: hsl(var(--opacity-16-hsl) / 0.1607843137254902);
--background-mod-strong: hsl(var(--opacity-20-hsl) / 0.2);
--background-mod-subtle: hsl(var(--opacity-8-hsl) / 0.0784313725490196);
--background-scrim: hsl(var(--opacity-black-72-hsl) / 0.7215686274509804);
--background-scrim-lightbox: hsl(var(--opacity-black-92-hsl) / 0.9215686274509803);
--background-secondary-alt: var(--primary-660);
--background-surface-high: var(--neutral-64);
--background-surface-higher: var(--neutral-62);
--background-surface-highest: var(--neutral-60);
--background-tile-gradient-pink-end: hsl(var(--illo-pink-70-hsl) / 0.3);
--background-tile-gradient-pink-start: hsl(var(--illo-pink-50-hsl) / 0.3);
--badge-background-brand: var(--blurple-50);
--badge-background-default: hsl(var(--opacity-16-hsl) / 0.1607843137254902);
--badge-expressive-background-default: var(--neutral-1);
--badge-expressive-text-default: var(--neutral-71);
--badge-notification-background: var(--red-new-46);
--badge-text-brand: var(--neutral-1);
--badge-text-default: var(--neutral-1);
--bg-surface-raised: var(--primary-560);
--border-feedback-critical: hsl(var(--opacity-red-20-hsl) / 0.2);
--border-focus: var(--blue-new-30);
--border-muted: hsl(var(--opacity-4-hsl) / 0.0392156862745098);
--border-normal: hsl(var(--opacity-20-hsl) / 0.2);
--border-strong: hsl(var(--opacity-44-hsl) / 0.4392156862745098);
--border-subtle: hsl(var(--opacity-12-hsl) / 0.12156862745098039);
--button-danger-background-disabled: var(--red-new-50);
--button-outline-brand-background-hover: var(--brand-500);
--button-outline-brand-border-active: var(--brand-560);
--button-outline-primary-text: var(--white);
--card-background-default: var(--neutral-64);
--card-primary-pressed-bg: var(--primary-645);
--card-secondary-bg: hsl(var(--opacity-8-hsl) / 0.0784313725490196);
--card-secondary-pressed-bg: var(--primary-645);
Show all properties (396 more)
}
:root {
--application-subscription-end: hsl(var(--application-subscription-end-hsl) / 1);
--application-subscription-end-hsl: 196.564 calc(var(--saturation-factor, 1) * 98.788%) 32.353%;
--application-subscription-start: hsl(var(--application-subscription-start-hsl) / 1);
--application-subscription-start-hsl: 234.909 calc(var(--saturation-factor, 1) * 68.465%) 52.745%;
--battlenet: hsl(var(--battlenet-hsl) / 1);
--battlenet-hsl: 199.651 calc(var(--saturation-factor, 1) * 100%) 44.902%;
--bg-animated-gradient-background-indigo-1: hsl(var(--bg-animated-gradient-background-indigo-1-hsl) / 1);
--bg-animated-gradient-background-indigo-1-hsl: 241.5 calc(var(--saturation-factor, 1) * 57.143%) 27.451%;
--bg-animated-gradient-background-indigo-2: hsl(var(--bg-animated-gradient-background-indigo-2-hsl) / 1);
--bg-animated-gradient-background-indigo-2-hsl: 257.059 calc(var(--saturation-factor, 1) * 100%) 20%;
--bg-animated-gradient-background-not-black: hsl(var(--bg-animated-gradient-background-not-black-hsl) / 1);
--bg-animated-gradient-background-not-black-hsl: 240 calc(var(--saturation-factor, 1) * 7.143%) 5.49%;
--bg-animated-gradient-background-pink-1: hsl(var(--bg-animated-gradient-background-pink-1-hsl) / 1);
--bg-animated-gradient-background-pink-1-hsl: 327.831 calc(var(--saturation-factor, 1) * 80.583%) 59.608%;
--bg-gradient-aurora-1: hsl(var(--bg-gradient-aurora-1-hsl) / 1);
--bg-gradient-aurora-1-hsl: 219.74 calc(var(--saturation-factor, 1) * 86.517%) 17.451%;
--bg-gradient-aurora-2: hsl(var(--bg-gradient-aurora-2-hsl) / 1);
--bg-gradient-aurora-2-hsl: 237.778 calc(var(--saturation-factor, 1) * 76.415%) 41.569%;
--bg-gradient-aurora-3: hsl(var(--bg-gradient-aurora-3-hsl) / 1);
--bg-gradient-aurora-3-hsl: 183.556 calc(var(--saturation-factor, 1) * 78.035%) 33.922%;
--bg-gradient-aurora-4: hsl(var(--bg-gradient-aurora-4-hsl) / 1);
--bg-gradient-aurora-4-hsl: 169.2 calc(var(--saturation-factor, 1) * 60.241%) 32.549%;
--bg-gradient-aurora-5: hsl(var(--bg-gradient-aurora-5-hsl) / 1);
--bg-gradient-aurora-5-hsl: 229.839 calc(var(--saturation-factor, 1) * 92.537%) 26.275%;
--bg-gradient-blurple-twilight-1: hsl(var(--bg-gradient-blurple-twilight-1-hsl) / 1);
--bg-gradient-blurple-twilight-1-hsl: 233.904 calc(var(--saturation-factor, 1) * 79.574%) 53.922%;
--bg-gradient-blurple-twilight-2: hsl(var(--bg-gradient-blurple-twilight-2-hsl) / 1);
--bg-gradient-blurple-twilight-2-hsl: 245.294 calc(var(--saturation-factor, 1) * 63.75%) 31.373%;
--bg-gradient-chroma-glow-1: hsl(var(--bg-gradient-chroma-glow-1-hsl) / 1);
--bg-gradient-chroma-glow-1-hsl: 183.39 calc(var(--saturation-factor, 1) * 86.341%) 40.196%;
--bg-gradient-chroma-glow-2: hsl(var(--bg-gradient-chroma-glow-2-hsl) / 1);
--bg-gradient-chroma-glow-2-hsl: 258.113 calc(var(--saturation-factor, 1) * 89.831%) 46.275%;
--bg-gradient-chroma-glow-3: hsl(var(--bg-gradient-chroma-glow-3-hsl) / 1);
--bg-gradient-chroma-glow-3-hsl: 298.491 calc(var(--saturation-factor, 1) * 90.857%) 34.314%;
--bg-gradient-chroma-glow-4: hsl(var(--bg-gradient-chroma-glow-4-hsl) / 1);
--bg-gradient-chroma-glow-4-hsl: 264.767 calc(var(--saturation-factor, 1) * 100%) 66.275%;
--bg-gradient-chroma-glow-5: hsl(var(--bg-gradient-chroma-glow-5-hsl) / 1);
--bg-gradient-chroma-glow-5-hsl: 206.702 calc(var(--saturation-factor, 1) * 75.494%) 50.392%;
--bg-gradient-citrus-sherbert-1: hsl(var(--bg-gradient-citrus-sherbert-1-hsl) / 1);
--bg-gradient-citrus-sherbert-1-hsl: 39.683 calc(var(--saturation-factor, 1) * 88.732%) 58.235%;
--bg-gradient-citrus-sherbert-2: hsl(var(--bg-gradient-citrus-sherbert-2-hsl) / 1);
--bg-gradient-citrus-sherbert-2-hsl: 18 calc(var(--saturation-factor, 1) * 81.522%) 63.922%;
--bg-gradient-cotton-candy-1: hsl(var(--bg-gradient-cotton-candy-1-hsl) / 1);
--bg-gradient-cotton-candy-1-hsl: 349.315 calc(var(--saturation-factor, 1) * 76.842%) 81.373%;
--bg-gradient-cotton-candy-2: hsl(var(--bg-gradient-cotton-candy-2-hsl) / 1);
--bg-gradient-cotton-candy-2-hsl: 226.4 calc(var(--saturation-factor, 1) * 92.593%) 84.118%;
--bg-gradient-crimson-moon-1: hsl(var(--bg-gradient-crimson-moon-1-hsl) / 1);
--bg-gradient-crimson-moon-1-hsl: 0 calc(var(--saturation-factor, 1) * 88.608%) 30.98%;
--bg-gradient-crimson-moon-2: hsl(var(--bg-gradient-crimson-moon-2-hsl) / 1);
--bg-gradient-crimson-moon-2-hsl: 0 calc(var(--saturation-factor, 1) * 0%) 0%;
Show all properties (526 more)
}
.theme-dark {
--legacy-elevation-low: 0 1px 5px 0 var(--opacity-black-28);
--legacy-elevation-high: 0 2px 10px 0 var(--opacity-black-20);
--legacy-elevation-border: 0 0 0 1px hsl(var(--primary-700-hsl) / 0.6);
}
:root {
--legacy-elevation-low: 0 1px 5px var(--opacity-black-20);
--legacy-elevation-high: 0 2px 10px 0 var(--opacity-black-8);
--legacy-elevation-border: 0 0 0 1px hsl(var(--primary-300-hsl) / 0.3);
}
:root {
--custom-paginator-round-button-size: 28px;
}
:root {
--custom-app-launcher-sticky-header-height: 66px;
--custom-app-launcher-container-border-radius: var(--radius-sm);
}
:root {
--custom-app-launcher-sticky-header-height: 66px;
--custom-app-launcher-container-border-radius: var(--radius-sm);
}
:root {
--custom-channel-members-bg: var(--background-base-lower);
}
:root {
--custom-user-profile-banner-height: 0;
--custom-user-profile-theme-padding: 0;
--custom-user-profile-base-layer-z-index: 0;
--custom-user-profile-bottom-layer-z-index: 1;
--custom-user-profile-middle-layer-z-index: 2;
--custom-user-profile-top-layer-z-index: 3;
--custom-user-profile-hoist-z-index: 4;
--custom-user-profile-toast-z-index: 5;
}
.root, [data-popout-root], :root {
--__spoiler-background-color--hidden: var(--spoiler-hidden-background);
--__spoiler-background-color--hidden--hover: var(--spoiler-hidden-background-hover);
--__spoiler-background-color--revealed: var(--background-mod-subtle);
--__spoiler-text-color--hidden: transparent;
--__spoiler-warning-text-color: var(--primary-200);
--__spoiler-warning-text-color--hover: var(--white);
--__spoiler-warning-background-color: var(--opacity-black-60);
--__spoiler-warning-background-color--hover: var(--opacity-black-88);
--__spoiler-container-box-shadow-color: var(--opacity-black-8);
--__obscured-background-blur-radius: 40px;
--__obscured-background-brightness: 0.55;
}
.theme-dark {
--brightness: calc(1.5 - var(--saturation-factor, 1) * 0.5);
--contrast: var(--saturation-factor, 1);
}
:root {
--expand-structural-duration: 100ms;
--expand-fade-duration: 200ms;
--expand-easing-function: ease-out;
--collapse-structural-duration: 150ms;
--collapse-fade-duration: 150ms;
--collapse-easing-function: ease-in;
}
.appMount__51fd7, body, html {
height: 100%;
width: 100%;
}
a, abbr, acronym, address, applet, big, blockquote, body, caption, cite, code, dd, del, dfn, div, dl, dt, em, fieldset, form, h1, h2, h3, h4, h5, h6, html, iframe, img, ins, kbd, label, legend, li, object, ol, p, pre, q, s, samp, small, span, strike, strong, table, tbody, td, tfoot, th, thead, tr, tt, ul, var {
border: 0;
font-family: inherit;
font-size: 100%;
font-style: inherit;
font-weight: inherit;
margin: 0;
padding: 0;
vertical-align: baseline;
}
[data-popout-root], html {
--brand-05a: hsla(var(--brand-500-hsl) / 0.05);
--brand-10a: hsla(var(--brand-500-hsl) / 0.1);
--brand-15a: hsla(var(--brand-500-hsl) / 0.15);
--brand-20a: hsla(var(--brand-500-hsl) / 0.2);
--brand-25a: hsla(var(--brand-500-hsl) / 0.25);
--brand-30a: hsla(var(--brand-500-hsl) / 0.3);
--brand-35a: hsla(var(--brand-500-hsl) / 0.35);
--brand-40a: hsla(var(--brand-500-hsl) / 0.4);
--brand-45a: hsla(var(--brand-500-hsl) / 0.45);
--brand-50a: hsla(var(--brand-500-hsl) / 0.5);
--brand-55a: hsla(var(--brand-500-hsl) / 0.55);
--brand-60a: hsla(var(--brand-500-hsl) / 0.6);
--brand-65a: hsla(var(--brand-500-hsl) / 0.65);
--brand-70a: hsla(var(--brand-500-hsl) / 0.7);
--brand-75a: hsla(var(--brand-500-hsl) / 0.75);
--brand-80a: hsla(var(--brand-500-hsl) / 0.8);
--brand-85a: hsla(var(--brand-500-hsl) / 0.85);
--brand-90a: hsla(var(--brand-500-hsl) / 0.9);
--brand-95a: hsla(var(--brand-500-hsl) / 0.95);
}
html[Attributes Style] {
-webkit-locale: "en-US";
}
user agent stylesheet
:root {
view-transition-name: root;
}
user agent stylesheet
html {
display: block;
}
<style>
--custom-voice-invite-suggestions-timer-progress {
syntax: "<number>";
inherits: false;
initial-value: 0;
}

File diff suppressed because one or more lines are too long