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:*)",
"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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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">
</head>
<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 { useQuery, useMutation, useConvex } from 'convex/react';
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { useQuery, usePaginatedQuery, useMutation, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -27,14 +27,71 @@ const heartIcon = getEmojiUrl('symbols', 'heart');
const thumbsupIcon = getEmojiUrl('people', 'thumbsup');
import GifPicker from './GifPicker';
// Cache for link metadata to prevent pop-in
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 [metadata, setMetadata] = useState(metadataCache.get(url) || null);
const [loading, setLoading] = useState(!metadataCache.has(url));
const [playing, setPlaying] = useState(false);
const [showControls, setShowControls] = useState(false);
const videoRef = useRef(null);
useEffect(() => {
if (metadataCache.has(url)) {
@@ -61,15 +118,6 @@ const LinkPreview = ({ url }) => {
return () => { isMounted = false; };
}, [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 isYouTube = !!videoId;
@@ -78,9 +126,7 @@ const LinkPreview = ({ url }) => {
if (metadata.video && !isYouTube) {
const handlePlayClick = () => {
setShowControls(true);
if (videoRef.current) {
videoRef.current.play();
}
if (videoRef.current) videoRef.current.play();
};
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 [url, setUrl] = useState(attachmentCache.get(metadata.url) || null);
const [loading, setLoading] = useState(!attachmentCache.has(metadata.url));
@@ -243,24 +281,30 @@ const PendingFilePreview = ({ file, onRemove }) => {
<ColoredIcon src={icon} color="#fff" size="16px" />
</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' }}>
{preview ? (
isVideo ? (
let previewContent;
if (preview && isVideo) {
previewContent = (
<>
<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>
</>
) : (
<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={{ fontSize: '24px' }}>📄</div>
<div style={{ fontSize: '10px', color: '#b9bbbe', marginTop: '4px', maxWidth: '90px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</div>
</div>
)}
);
}
return (
<div style={{ display: 'inline-flex', flexDirection: 'column', marginRight: '10px' }}>
<div style={{ position: 'relative', width: '200px', height: '200px', borderRadius: '8px', backgroundColor: '#2f3136', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
{previewContent}
<div style={{ position: 'absolute', top: '4px', right: '4px', display: 'flex', gap: '4px', padding: '4px' }}>
<ActionButton icon={SpoilerIcon} 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('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>
<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" />} />
{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" />} />}
<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={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={() => onAddReaction(null)} title="Add Reaction" emoji={<ColoredIcon src={EmojieIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
{isOwner && <IconButton onClick={onEdit} title="Edit" emoji={<ColoredIcon src={EditIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />}
<IconButton onClick={onReply} title="Reply" emoji={<ColoredIcon src={ReplyIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
<IconButton onClick={onMore} title="More" emoji={<ColoredIcon src={MoreIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
</div>
);
@@ -325,7 +369,7 @@ const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner }) => {
const [pos, setPos] = useState({ top: y, left: x });
useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); return () => window.removeEventListener('click', h); }, [onClose]);
React.useLayoutEffect(() => {
if (menuRef.current) {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
let newTop = y, newLeft = x;
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 (newTop < 0) newTop = 10;
setPos({ top: newTop, left: newLeft });
}
}, [x, y]);
const MenuItem = ({ label, iconSrc, iconColor, onClick, danger }) => (
<div onClick={(e) => { e.stopPropagation(); onClick(); onClose(); }} style={{ display: 'flex', alignItems: 'center', padding: '10px 12px', cursor: 'pointer', fontSize: '14px', color: danger ? '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>
<div style={{ marginLeft: '12px' }}><ColoredIcon src={iconSrc} color={iconColor} size="18px" /></div>
</div>
@@ -345,13 +388,13 @@ const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner }) => {
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()}>
<MenuItem label="Add Reaction" iconSrc={EmojieIcon} iconColor='color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)' 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')} />}
<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="Add Reaction" iconSrc={EmojieIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('reaction')} />
{isOwner && <MenuItem label="Edit Message" iconSrc={EditIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('edit')} />}
<MenuItem label="Reply" iconSrc={ReplyIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('reply')} />
<div style={{ height: '1px', backgroundColor: '#36393f', margin: '4px 0' }} />
<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' }} />
{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>
);
};
@@ -362,29 +405,75 @@ const ColoredIcon = ({ src, color, size = '24px', style = {} }) => (
</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 [decryptedMessages, setDecryptedMessages] = useState([]);
const [input, setInput] = useState('');
const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null);
const inputDivRef = useRef(null);
const [zoomedImage, setZoomedImage] = useState(null);
const [showGifPicker, setShowGifPicker] = useState(false);
const [pickerActiveTab, setPickerActiveTab] = useState('GIFs');
const savedRangeRef = useRef(null);
const [pickerTab, setPickerTab] = useState(null);
const [isDragging, setIsDragging] = useState(false);
const [pendingFiles, setPendingFiles] = useState([]);
const [hasImages, setHasImages] = useState(false);
const [isMultiline, setIsMultiline] = useState(false);
const [hoveredMessageId, setHoveredMessageId] = 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();
// Convex reactive queries - replaces socket.io listeners!
const rawMessages = useQuery(
const { results: rawMessages, status, loadMore } = usePaginatedQuery(
api.messages.list,
channelId ? { channelId, userId: currentUserId || undefined } : "skip"
channelId ? { channelId, userId: currentUserId || undefined } : "skip",
{ initialNumItems: 50 }
);
const typingData = useQuery(
@@ -392,32 +481,20 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
channelId ? { channelId } : "skip"
) || [];
// Convex mutations
const sendMessageMutation = useMutation(api.messages.send);
const addReaction = useMutation(api.reactions.add);
const removeReaction = useMutation(api.reactions.remove);
const startTypingMutation = useMutation(api.typing.startTyping);
const stopTypingMutation = useMutation(api.typing.stopTyping);
const typingTimeoutRef = useRef(null);
const lastTypingEmitRef = useRef(0);
const showGifPicker = pickerTab !== null;
// Close GIF picker when clicking outside
useEffect(() => {
const handleClickOutside = () => { if (showGifPicker) setShowGifPicker(false); };
const handleClickOutside = () => { if (showGifPicker) setPickerTab(null); };
window.addEventListener('click', handleClickOutside);
return () => window.removeEventListener('click', handleClickOutside);
}, [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) => {
try {
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) => {
if (!msg.signature || !msg.public_signing_key) return false;
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; }
};
// Decrypt messages when raw messages change
useEffect(() => {
if (!rawMessages) return;
if (!rawMessages || rawMessages.length === 0) {
setDecryptedMessages([]);
return;
}
const cache = decryptionCacheRef.current;
let cancelled = false;
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 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);
};
processMessages();
return () => { cancelled = true; };
}, [rawMessages, channelKey]);
// Clear messages on channel change
// Clear decryption cache and reset scroll state on channel/key change
useEffect(() => {
decryptionCacheRef.current.clear();
setDecryptedMessages([]);
}, [channelId]);
isInitialLoadRef.current = true;
prevResultsLengthRef.current = 0;
}, [channelId, channelKey]);
// Filter typing users (exclude self)
const typingUsers = typingData.filter(t => t.username !== username);
const scrollToBottom = useCallback((force = false) => {
@@ -475,14 +576,75 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
}
}, []);
useEffect(() => {
if (decryptedMessages.length > 0) {
const lastMsg = decryptedMessages[decryptedMessages.length - 1];
scrollToBottom(lastMsg.username === username);
}
}, [decryptedMessages, username, scrollToBottom]);
// Scroll management via useLayoutEffect (fires before paint)
useLayoutEffect(() => {
const container = messagesContainerRef.current;
if (!container || decryptedMessages.length === 0) return;
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) => {
if (!inputDivRef.current) return;
@@ -508,13 +670,16 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
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 match = content.match(/:([a-zA-Z0-9_]+):$/);
if (match) {
if (!match) return;
const name = match[1];
const emoji = AllEmojis.find(e => e.name === name);
if (emoji) {
if (!emoji) return;
const img = document.createElement('img');
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";
@@ -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); }
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 [uploading, setUploading] = useState(false);
const processFile = (file) => { setPendingFiles(prev => [...prev, file]); };
const handleFileSelect = (e) => { if (e.target.files && e.target.files.length > 0) Array.from(e.target.files).forEach(processFile); };
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); if (!isDragging) setIsDragging(true); };
@@ -547,12 +707,10 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
const encryptedBytes = fromHexString(encryptedHex);
const blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
// Upload to Convex storage
const uploadUrl = await convex.mutation(api.files.generateUploadUrl, {});
const uploadRes = await fetch(uploadUrl, { method: 'POST', headers: { 'Content-Type': blob.type }, body: blob });
const { storageId } = await uploadRes.json();
// Get the file URL
const fileUrl = await convex.query(api.files.getFileUrl, { storageId });
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) => {
e.preventDefault();
let messageContent = '';
@@ -605,6 +771,7 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
if (!messageContent && pendingFiles.length === 0) return;
setUploading(true);
userSentMessageRef.current = true;
try {
for (const file of pendingFiles) await uploadAndSendFile(file);
setPendingFiles([]);
@@ -612,11 +779,7 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
await sendMessage(messageContent);
if (inputDivRef.current) inputDivRef.current.innerHTML = '';
setInput(''); setHasImages(false);
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
if (currentUserId && channelId) {
stopTypingMutation({ channelId, userId: currentUserId }).catch(() => {});
}
lastTypingEmitRef.current = 0;
clearTypingState();
}
} catch (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) => {
if (!currentUserId) return;
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 (
<div className="chat-area" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} style={{ position: 'relative' }}>
{isDragging && <DragOverlay />}
<div className="messages-list" ref={messagesContainerRef}>
<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) => {
const currentDate = new Date(msg.created_at);
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 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 (
<React.Fragment key={msg.id || idx}>
{isNewDay && <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 }); }}>
{isNewDay(currentDate, previousDate) && <div className="date-divider"><span>{currentDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}</span></div>}
<div className={`message-item${isGrouped ? ' message-grouped' : ''}`} style={isMentioned ? { background: 'color-mix(in oklab,hsl(36.894 calc(1*100%) 31.569% /0.0784313725490196) 100%,hsl(0 0% 0% /0.0784313725490196) 0%)', position: 'relative' } : { position: 'relative' }} onMouseEnter={() => setHoveredMessageId(msg.id)} onMouseLeave={() => setHoveredMessageId(null)} onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner }); }}>
{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" style={{ backgroundColor: getUserColor(msg.username || 'Unknown') }}>
<div className="message-avatar" style={{ backgroundColor: userColor }}>
{(msg.username || '?').substring(0, 1).toUpperCase()}
</div>
</div>
)}
<div className="message-body">
{!isGrouped && (
<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>}
<span className="timestamp">{currentDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span>
</div>
)}
<div style={{ position: 'relative' }}>
<div className="message-content">
{isAttachment ? <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'));
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>
)}
{renderMessageContent(msg)}
{renderReactions(msg)}
</div>
{hoveredMessageId === msg.id && (
<MessageToolbar isOwner={msg.username === username}
onAddReaction={(emoji) => { const emojiName = emoji || 'heart'; addReaction({ messageId: msg.id, userId: currentUserId, emoji: emojiName }); }}
<MessageToolbar isOwner={isOwner}
onAddReaction={(emoji) => { addReaction({ messageId: msg.id, userId: currentUserId, emoji: emoji || 'heart' }); }}
onEdit={() => console.log('Edit', msg.id)}
onReply={() => console.log('Reply', msg.id)}
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>
@@ -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" />}
</button>
<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(); }}
onMouseUp={() => { const sel = window.getSelection(); if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange(); }}
onKeyUp={() => { const sel = window.getSelection(); if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange(); }}
onBlur={saveSelection}
onMouseUp={saveSelection}
onKeyUp={saveSelection}
onInput={(e) => {
setInput(e.currentTarget.textContent);
setHasImages(e.currentTarget.querySelectorAll('img').length > 0);
const text = e.currentTarget.innerText;
setIsMultiline(text.includes('\n') || e.currentTarget.scrollHeight > 50);
checkTypedEmoji();
// Handle Typing via Convex
const now = Date.now();
if (now - lastTypingEmitRef.current > 2000 && currentUserId && channelId) {
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>}
<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); } }}>
<ColoredIcon src={GifIcon} color={(showGifPicker && pickerActiveTab === 'GIFs') ? '#5865f2' : ICON_COLOR_DEFAULT} size="24px" />
<button type="button" className="chat-input-icon-btn" title="GIF" onClick={(e) => { e.stopPropagation(); togglePicker('GIFs'); }}>
<ColoredIcon src={GifIcon} color={pickerTab === 'GIFs' ? '#5865f2' : ICON_COLOR_DEFAULT} size="24px" />
</button>
{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>
<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>
</form>

View File

@@ -3,44 +3,199 @@ import React, { useState, useEffect, useRef } from 'react';
import { useConvex } from 'convex/react';
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 [search, setSearch] = useState('');
const [categories, setCategories] = useState([]);
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
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 setActiveTab = (tab) => {
if (onTabChange) onTabChange(tab);
if (currentTab === undefined) setInternalActiveTab(tab);
};
const [emojiCategories, setEmojiCategories] = useState({});
const [collapsedCategories, setCollapsedCategories] = useState({});
const inputRef = useRef(null);
const convex = useConvex();
useEffect(() => {
// Fetch categories via Convex action
convex.action(api.gifs.categories, {})
.then(data => {
if (data.categories) setCategories(data.categories);
})
.catch(err => console.error('Failed to load categories', err));
// Auto 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);
if (inputRef.current) inputRef.current.focus();
}, []);
useEffect(() => {
@@ -64,10 +219,6 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
return () => clearTimeout(timeout);
}, [search, activeTab]);
const handleCategoryClick = (categoryName) => {
setSearch(categoryName);
};
const toggleCategory = (categoryName) => {
setCollapsedCategories(prev => ({
...prev,
@@ -161,170 +312,9 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
{loading ? (
<div style={{ color: '#b9bbbe', textAlign: 'center', padding: '20px' }}>Loading...</div>
) : activeTab === 'GIFs' ? (
// GIF Tab Logic (Search Results OR Categories)
(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>
<GifContent search={search} results={results} categories={categories} onSelect={onSelect} onCategoryClick={setSearch} />
) : (
// GIF Categories
<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>
<EmojiContent search={search} onSelect={onSelect} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} />
)}
</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 { api } from '../../../../convex/_generated/api';
@@ -51,13 +51,9 @@ const ServerSettingsModal = ({ onClose }) => {
};
const handleAssignRole = async (roleId, targetUserId, isAdding) => {
const action = isAdding ? api.roles.assign : api.roles.unassign;
try {
if (isAdding) {
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
await convex.mutation(action, { roleId, userId: targetUserId });
} catch (e) {
console.error('Failed to assign/unassign role:', e);
}
@@ -92,17 +88,21 @@ const ServerSettingsModal = ({ onClose }) => {
</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 = () => (
<div style={{ display: 'flex', height: '100%' }}>
{/* Role List */}
<div style={{ width: '200px', borderRight: '1px solid #3f4147', marginRight: '20px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '10px' }}>
<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>
)}
</div>
{roles.filter(r => r.name !== 'Owner').map(r => (
{editableRoles.map(r => (
<div
key={r._id}
onClick={() => setSelectedRole(r)}
@@ -119,29 +119,28 @@ const ServerSettingsModal = ({ onClose }) => {
))}
</div>
{/* Edit Panel */}
{selectedRole ? (
<div className="no-scrollbar" style={{ flex: 1, overflowY: 'auto' }}>
<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
value={selectedRole.name}
onChange={(e) => handleUpdateRole(selectedRole._id, { name: e.target.value })}
disabled={!myPermissions.manage_roles}
style={{ width: '100%', padding: 10, background: '#202225', border: 'none', borderRadius: 4, color: 'white', marginBottom: 20, opacity: !myPermissions.manage_roles ? 0.5 : 1 }}
disabled={!canManageRoles}
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
type="color"
value={selectedRole.color}
onChange={(e) => handleUpdateRole(selectedRole._id, { color: e.target.value })}
disabled={!myPermissions.manage_roles}
style={{ width: '100%', height: 40, border: 'none', padding: 0, marginBottom: 20, opacity: !myPermissions.manage_roles ? 0.5 : 1 }}
disabled={!canManageRoles}
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 => (
<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>
@@ -149,17 +148,17 @@ const ServerSettingsModal = ({ onClose }) => {
type="checkbox"
checked={selectedRole.permissions?.[perm] || false}
onChange={(e) => {
const newPerms = { ...selectedRole.permissions, [perm]: e.target.checked };
handleUpdateRole(selectedRole._id, { permissions: newPerms });
handleUpdateRole(selectedRole._id, {
permissions: { ...selectedRole.permissions, [perm]: e.target.checked }
});
}}
disabled={!myPermissions.manage_roles}
style={{ transform: 'scale(1.5)', opacity: !myPermissions.manage_roles ? 0.5 : 1 }}
disabled={!canManageRoles}
style={{ transform: 'scale(1.5)', opacity: disabledOpacity }}
/>
</div>
))}
{/* Prevent deleting Default Roles */}
{myPermissions.manage_roles && selectedRole.name !== 'Owner' && selectedRole.name !== '@everyone' && (
{canManageRoles && 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' }}>
Delete Role
</button>
@@ -182,18 +181,18 @@ const ServerSettingsModal = ({ onClose }) => {
<div style={{ flex: 1 }}>
<div style={{ color: 'white', fontWeight: 'bold' }}>{m.username}</div>
<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 }}>
{r.name}
</span>
))}
</div>
</div>
{canManageRoles && (
<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);
return (
myPermissions.manage_roles && (
<button
key={r._id}
onClick={() => handleAssignRole(r._id, m.id, !hasRole)}
@@ -205,15 +204,23 @@ const ServerSettingsModal = ({ onClose }) => {
}}
title={hasRole ? `Remove ${r.name}` : `Add ${r.name}`}
/>
)
);
})}
</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 (
<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()}
@@ -222,9 +229,7 @@ const ServerSettingsModal = ({ onClose }) => {
<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>
</div>
{activeTab === 'Roles' && renderRolesTab()}
{activeTab === 'Members' && renderMembersTab()}
{activeTab === 'Overview' && <div style={{ color: '#b9bbbe' }}>Server Name: Secure Chat<br/>Region: US-East</div>}
{renderTabContent()}
</div>
</div>
);

View File

@@ -17,7 +17,40 @@ import disconnectIcon from '../assets/icons/disconnect.svg';
import cameraIcon from '../assets/icons/camera.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' }) => (
<div style={{
width: size,
@@ -46,18 +79,6 @@ const UserControlPanel = ({ username }) => {
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 (
<div style={{
height: '64px',
@@ -94,7 +115,6 @@ const UserControlPanel = ({ username }) => {
}}>
{(username || '?').substring(0, 1).toUpperCase()}
</div>
{/* Status Indicator */}
<div style={{
position: 'absolute',
bottom: '-2px',
@@ -118,57 +138,19 @@ const UserControlPanel = ({ username }) => {
{/* Controls */}
<div style={{ display: 'flex' }}>
<button
onClick={toggleMute}
title={effectiveMute ? "Unmute" : "Mute"}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '6px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<button onClick={toggleMute} title={effectiveMute ? "Unmute" : "Mute"} style={controlButtonStyle}>
<ColoredIcon
src={effectiveMute ? mutedIcon : muteIcon}
color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
/>
</button>
<button
onClick={toggleDeafen}
title={isDeafened ? "Undeafen" : "Deafen"}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '6px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<button onClick={toggleDeafen} title={isDeafened ? "Undeafen" : "Deafen"} style={controlButtonStyle}>
<ColoredIcon
src={isDeafened ? defeanedIcon : defeanIcon}
color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
/>
</button>
<button
title="User Settings"
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '6px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<button title="User Settings" style={controlButtonStyle}>
<ColoredIcon
src={settingsIcon}
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 [isCreating, setIsCreating] = useState(false);
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
@@ -191,14 +259,10 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
const convex = useConvex();
// Callbacks for Modal - Convex is reactive, no need to manually refresh
const onRenameChannel = (id, newName) => {
// Convex reactive queries auto-update
};
const onRenameChannel = () => {};
const onDeleteChannel = (id) => {
if (activeChannel === id) onSelectChannel(null);
// Convex reactive queries auto-update
};
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) => {
if (e) e.preventDefault();
if (!newChannelName.trim()) {
setIsCreating(false);
return;
}
const name = newChannelName.trim();
const type = newChannelType;
const userId = localStorage.getItem('userId');
if (!userId) {
@@ -227,54 +291,19 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
}
try {
// 1. Create Channel via Convex
const { id: channelId } = await convex.mutation(api.channels.create, { name, type });
// 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;
const { id: channelId } = await convex.mutation(api.channels.create, { name, type: newChannelType });
const keyHex = randomHex(32);
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);
}
}
// 4. Upload Keys Batch via Convex
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
// No need to notify - Convex queries are reactive!
await encryptKeyForUsers(convex, channelId, keyHex);
} catch (keyErr) {
console.error("Critical: Failed to distribute keys", keyErr);
alert("Channel created but key distribution failed.");
}
// 5. Done - Convex reactive queries auto-update the channel list
setIsCreating(false);
} catch (err) {
console.error(err);
alert("Failed to create channel: " + err.message);
} finally {
setIsCreating(false);
}
};
@@ -286,14 +315,6 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
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 targetChannelId = generalChannel ? generalChannel._id : activeChannel;
@@ -302,27 +323,21 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
return;
}
const targetKey = channelKeys ? channelKeys[targetChannelId] : null;
const targetKey = channelKeys?.[targetChannelId];
if (!targetKey) {
alert("Error: You don't have the key for this channel yet, so you can't invite others.");
return;
}
const payload = JSON.stringify({
[targetChannelId]: targetKey
});
try {
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 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, {
code: inviteCode,
encryptedPayload: blob,
@@ -330,50 +345,27 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
keyVersion: 1
});
// 5. Show Link
const link = `http://localhost:5173/#/register?code=${inviteCode}&key=${inviteSecret}`;
navigator.clipboard.writeText(link);
alert(`Invite Link Copied to Clipboard!\n\n${link}`);
} catch (e) {
console.error("Invite Error:", e);
alert("Failed to create invite. See console.");
}
};
// Screen Share Handler
const handleScreenShareSelect = async (selection) => {
if (!room) return;
try {
// Unpublish existing screen share if any
if (room.localParticipant.isScreenShareEnabled) {
await room.localParticipant.setScreenShareEnabled(false);
}
// Capture based on 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 stream = await navigator.mediaDevices.getUserMedia(getScreenCaptureConstraints(selection));
const track = stream.getVideoTracks()[0];
if (track) {
if (!track) return;
await room.localParticipant.publishTrack(track, {
name: 'screen_share',
source: Track.Source.ScreenShare
@@ -385,15 +377,12 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
setScreenSharing(false);
room.localParticipant.setScreenShareEnabled(false).catch(console.error);
};
}
} catch (err) {
console.error("Error sharing screen:", err);
alert("Failed to share screen: " + err.message);
}
};
// Toggle Modal instead of direct toggle
const handleScreenShareClick = () => {
if (room?.localParticipant.isScreenShareEnabled) {
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 (
<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' ? (
const renderDMView = () => (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<DMList
dmChannels={dmChannels}
activeDMChannel={activeDMChannel}
onSelectDM={(dm) => {
if (dm === 'friends') {
setActiveDMChannel(null);
} else {
setActiveDMChannel(dm);
}
}}
onSelectDM={(dm) => setActiveDMChannel(dm === 'friends' ? null : dm)}
onOpenDM={onOpenDM}
/>
</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-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span
@@ -459,39 +455,15 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
Secure Chat
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handleStartCreate}
title="Create New Channel"
style={{
background: 'transparent',
border: 'none',
color: '#b9bbbe',
cursor: 'pointer',
fontSize: '18px',
padding: '0 4px',
marginRight: '4px'
}}
>
<button onClick={handleStartCreate} title="Create New Channel" style={{ ...headerButtonStyle, marginRight: '4px' }}>
+
</button>
<button
onClick={handleCreateInvite}
title="Create Invite Link"
style={{
background: 'transparent',
border: 'none',
color: '#b9bbbe',
cursor: 'pointer',
fontSize: '18px',
padding: '0 4px'
}}
>
<button onClick={handleCreateInvite} title="Create Invite Link" style={headerButtonStyle}>
🔗
</button>
</div>
</div>
{/* Inline Create Channel Input */}
{isCreating && (
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
<form onSubmit={handleSubmitCreate}>
@@ -531,17 +503,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
<React.Fragment key={channel._id}>
<div
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
onClick={() => {
if (channel.type === 'voice') {
if (voiceChannelId === channel._id) {
onSelectChannel(channel._id);
} else {
connectToVoice(channel._id, channel.name, localStorage.getItem('userId'));
}
} else {
onSelectChannel(channel._id);
}
}}
onClick={() => handleChannelClick(channel)}
style={{
position: 'relative',
display: 'flex',
@@ -556,10 +518,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
<ColoredIcon
src={voiceIcon}
size="16px"
color={voiceStates[channel._id]?.length > 0
? "color-mix(in oklab, hsl(132.809 calc(1*34.902%) 50% /1) 100%, #000 0%)"
: "#8e9297"
}
color={voiceStates[channel._id]?.length > 0 ? VOICE_ACTIVE_COLOR : "#8e9297"}
/>
</div>
) : (
@@ -589,65 +548,41 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</button>
</div>
{channel.type === 'voice' && voiceStates[channel._id] && voiceStates[channel._id].length > 0 && (
<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>
)}
{renderVoiceUsers(channel)}
</React.Fragment>
))}
</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>
{/* 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' && (
<div style={{
backgroundColor: '#292b2f',
@@ -672,32 +607,18 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</div>
<div style={{ color: '#dcddde', fontSize: 12, marginBottom: 8 }}>{voiceChannelName} / Secure Chat</div>
<div style={{ display: 'flex', gap: 4 }}>
<button
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'
}}
>
<button onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)} title="Turn On Camera" style={voicePanelButtonStyle}>
<ColoredIcon src={cameraIcon} color="#b9bbbe" size="20px" />
</button>
<button
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'
}}
>
<button onClick={handleScreenShareClick} title="Share Screen" style={voicePanelButtonStyle}>
<ColoredIcon src={screenIcon} color="#b9bbbe" size="20px" />
</button>
</div>
</div>
)}
{/* User Control Panel at Bottom, Spanning Full Width */}
<UserControlPanel username={username} />
{/* Modals */}
{editingChannel && (
<ChannelSettingsModal
channel={editingChannel}

View File

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

View File

@@ -273,6 +273,28 @@ body {
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 {
width: 40px;
margin-right: 16px;
@@ -631,6 +653,54 @@ body {
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 */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,26 @@
import { query, mutation } from "./_generated/server";
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
export const list = query({
@@ -49,7 +70,6 @@ export const create = mutation({
throw new Error("Channel name required");
}
// Check for duplicate name
const existing = await ctx.db
.query("channels")
.withIndex("by_name", (q) => q.eq("name", args.name))
@@ -105,13 +125,12 @@ export const remove = mutation({
throw new Error("Channel not found");
}
// Delete messages
// Delete reactions for all messages in this channel
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.id))
.collect();
for (const msg of messages) {
// Delete reactions for this message
const reactions = await ctx.db
.query("messageReactions")
.withIndex("by_message", (q) => q.eq("messageId", msg._id))
@@ -122,43 +141,11 @@ export const remove = mutation({
await ctx.db.delete(msg._id);
}
// Delete channel keys
const keys = await ctx.db
.query("channelKeys")
.withIndex("by_channel", (q) => q.eq("channelId", args.id))
.collect();
for (const key of keys) {
await ctx.db.delete(key._id);
}
await deleteByChannel(ctx, "channelKeys", args.id);
await deleteByChannel(ctx, "dmParticipants", args.id);
await deleteByChannel(ctx, "typingIndicators", args.id);
await deleteByChannel(ctx, "voiceStates", args.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);
return { success: true };

View File

@@ -1,7 +1,6 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
// Find-or-create DM channel between two users
export const openDM = mutation({
args: {
userId: v.id("userProfiles"),
@@ -16,11 +15,9 @@ export const openDM = mutation({
throw new Error("Cannot DM yourself");
}
// Deterministic channel name
const sorted = [args.userId, args.targetUserId].sort();
const dmName = `dm-${sorted[0]}-${sorted[1]}`;
// Check if already exists
const existing = await ctx.db
.query("channels")
.withIndex("by_name", (q) => q.eq("name", dmName))
@@ -30,27 +27,20 @@ export const openDM = mutation({
return { channelId: existing._id, created: false };
}
// Create DM channel
const channelId = await ctx.db.insert("channels", {
name: dmName,
type: "dm",
});
// Add participants
await ctx.db.insert("dmParticipants", {
channelId,
userId: args.userId,
});
await ctx.db.insert("dmParticipants", {
channelId,
userId: args.targetUserId,
});
await Promise.all([
ctx.db.insert("dmParticipants", { channelId, userId: args.userId }),
ctx.db.insert("dmParticipants", { channelId, userId: args.targetUserId }),
]);
return { channelId, created: true };
},
});
// List user's DM channels with other user info
export const listDMs = query({
args: { userId: v.id("userProfiles") },
returns: v.array(
@@ -62,43 +52,36 @@ export const listDMs = query({
})
),
handler: async (ctx, args) => {
// Get all DM participations for this user
const myParticipations = await ctx.db
.query("dmParticipants")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
const result: Array<{
channel_id: typeof myParticipations[0]["channelId"];
channel_name: string;
other_user_id: string;
other_username: string;
}> = [];
for (const part of myParticipations) {
const results = await Promise.all(
myParticipations.map(async (part) => {
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
.query("dmParticipants")
.withIndex("by_channel", (q) => q.eq("channelId", part.channelId))
.collect();
const otherPart = otherParts.find((p) => p.userId !== args.userId);
if (!otherPart) continue;
if (!otherPart) return null;
const otherUser = await ctx.db.get(otherPart.userId);
if (!otherUser) continue;
if (!otherUser) return null;
result.push({
return {
channel_id: part.channelId,
channel_name: channel.name,
other_user_id: otherUser._id,
other_user_id: otherUser._id as string,
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 { paginationOptsValidator } from "convex/server";
import { v } from "convex/values";
// List recent messages for a channel with reactions + username
export const list = query({
args: {
paginationOpts: paginationOptsValidator,
channelId: v.id("channels"),
userId: v.optional(v.id("userProfiles")),
},
returns: v.array(v.any()),
returns: v.any(),
handler: async (ctx, args) => {
const messages = await ctx.db
const result = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.take(50);
.paginate(args.paginationOpts);
// Reverse to get chronological order
const chronological = messages.reverse();
// Enrich with username, signing key, and reactions
const enriched = await Promise.all(
chronological.map(async (msg) => {
// Get sender info
const enrichedPage = await Promise.all(
result.page.map(async (msg) => {
const sender = await ctx.db.get(msg.senderId);
// Get reactions for this message
const reactionDocs = await ctx.db
.query("messageReactions")
.withIndex("by_message", (q) => q.eq("messageId", msg._id))
.collect();
// Aggregate reactions
const reactions: Record<
string,
{ count: number; me: boolean }
> = {};
const reactions: Record<string, { count: number; me: boolean }> = {};
for (const r of reactionDocs) {
if (!reactions[r.emoji]) {
reactions[r.emoji] = { count: 0, me: false };
}
reactions[r.emoji].count++;
const entry = (reactions[r.emoji] ??= { count: 0, me: false });
entry.count++;
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(),
username: sender?.username || "Unknown",
public_signing_key: sender?.publicSigningKey || "",
reactions:
Object.keys(reactions).length > 0 ? reactions : null,
reactions: Object.keys(reactions).length > 0 ? reactions : null,
};
})
);
return enriched;
return { ...result, page: enrichedPage };
},
});
// Send encrypted message
export const send = mutation({
args: {
channelId: v.id("channels"),
@@ -86,17 +73,14 @@ export const send = mutation({
signature: args.signature,
keyVersion: args.keyVersion,
});
return { id };
},
});
// Delete a message
export const remove = mutation({
args: { id: v.id("messages") },
returns: v.null(),
handler: async (ctx, args) => {
// Delete reactions first
const reactions = await ctx.db
.query("messageReactions")
.withIndex("by_message", (q) => q.eq("messageId", args.id))

View File

@@ -1,5 +1,30 @@
import { query, mutation } from "./_generated/server";
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
export const list = query({
@@ -49,18 +74,17 @@ export const update = mutation({
const role = await ctx.db.get(args.id);
if (!role) throw new Error("Role not found");
const { id, ...fields } = args;
const updates: Record<string, unknown> = {};
if (args.name !== undefined) updates.name = args.name;
if (args.color !== undefined) updates.color = args.color;
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);
for (const [key, value] of Object.entries(fields)) {
if (value !== undefined) updates[key] = value;
}
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);
if (!role) throw new Error("Role not found");
// Delete user_role assignments
const assignments = await ctx.db
.query("userRoles")
.withIndex("by_role", (q) => q.eq("roleId", args.id))
@@ -93,30 +116,14 @@ export const listMembers = query({
handler: async (ctx) => {
const users = await ctx.db.query("userProfiles").collect();
const result = await Promise.all(
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 {
return await Promise.all(
users.map(async (user) => ({
id: user._id,
username: user.username,
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() }),
handler: async (ctx, args) => {
// Check if already assigned
const existing = await ctx.db
.query("userRoles")
.withIndex("by_user_and_role", (q) =>
@@ -181,30 +187,21 @@ export const getMyPermissions = query({
attach_files: v.boolean(),
}),
handler: async (ctx, args) => {
const userRoleAssignments = await ctx.db
.query("userRoles")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
const roles = await getRolesForUser(ctx, args.userId);
const finalPerms = {
manage_channels: false,
manage_roles: false,
create_invite: false,
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;
const finalPerms: Record<string, boolean> = {};
for (const key of PERMISSION_KEYS) {
finalPerms[key] = roles.some(
(role) => (role.permissions as Record<string, boolean>)?.[key]
);
}
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 { internal } from "./_generated/api";
// Start typing indicator
const TYPING_TTL_MS = 6000;
export const startTyping = mutation({
args: {
channelId: v.id("channels"),
@@ -11,9 +12,8 @@ export const startTyping = mutation({
},
returns: v.null(),
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
.query("typingIndicators")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
@@ -32,14 +32,11 @@ export const startTyping = mutation({
});
}
// Schedule cleanup
await ctx.scheduler.runAfter(6000, internal.typing.cleanExpired, {});
await ctx.scheduler.runAfter(TYPING_TTL_MS, internal.typing.cleanExpired, {});
return null;
},
});
// Stop typing indicator
export const stopTyping = mutation({
args: {
channelId: v.id("channels"),
@@ -61,7 +58,6 @@ export const stopTyping = mutation({
},
});
// Get typing users for a channel (reactive!)
export const getTyping = query({
args: { channelId: v.id("channels") },
returns: v.array(
@@ -79,21 +75,17 @@ export const getTyping = query({
return indicators
.filter((t) => t.expiresAt > now)
.map((t) => ({
userId: t.userId,
username: t.username,
}));
.map((t) => ({ userId: t.userId, username: t.username }));
},
});
// Internal: clean expired typing indicators
export const cleanExpired = internalMutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
const now = Date.now();
const all = await ctx.db.query("typingIndicators").collect();
for (const t of all) {
const expired = await ctx.db.query("typingIndicators").collect();
for (const t of expired) {
if (t.expiresAt <= now) {
await ctx.db.delete(t._id);
}

View File

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