feat: Add initial Discord clone application with Convex backend services and Electron React frontend components.
This commit is contained in:
@@ -10,7 +10,8 @@
|
||||
"Bash(npx convex dev:*)",
|
||||
"Bash(npx convex:*)",
|
||||
"Bash(npx @convex-dev/auth:*)",
|
||||
"Bash(dir:*)"
|
||||
"Bash(dir:*)",
|
||||
"Bash(npx vite build:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
3
Frontend/Electron/src/assets/icons/close.svg
Normal file
3
Frontend/Electron/src/assets/icons/close.svg
Normal 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 |
4
Frontend/Electron/src/assets/icons/help.svg
Normal file
4
Frontend/Electron/src/assets/icons/help.svg
Normal 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 |
3
Frontend/Electron/src/assets/icons/inbox.svg
Normal file
3
Frontend/Electron/src/assets/icons/inbox.svg
Normal 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 |
3
Frontend/Electron/src/assets/icons/max.svg
Normal file
3
Frontend/Electron/src/assets/icons/max.svg
Normal 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 |
3
Frontend/Electron/src/assets/icons/min.svg
Normal file
3
Frontend/Electron/src/assets/icons/min.svg
Normal 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 |
3
Frontend/Electron/src/assets/icons/update.svg
Normal file
3
Frontend/Electron/src/assets/icons/update.svg
Normal 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 |
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
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 ``;
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
|
||||
103
convex/roles.ts
103
convex/roles.ts
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
836
discord html copy/Discord DM's/discord css.txt
Normal file
836
discord html copy/Discord DM's/discord css.txt
Normal 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;
|
||||
}
|
||||
132
discord html copy/Discord DM's/discord.txt
Normal file
132
discord html copy/Discord DM's/discord.txt
Normal file
File diff suppressed because one or more lines are too long
836
discord html copy/Discord Server/discord css.txt
Normal file
836
discord html copy/Discord Server/discord css.txt
Normal 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;
|
||||
}
|
||||
83
discord html copy/Discord Server/discord.txt
Normal file
83
discord html copy/Discord Server/discord.txt
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user