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 dev:*)",
|
||||||
"Bash(npx convex:*)",
|
"Bash(npx convex:*)",
|
||||||
"Bash(npx @convex-dev/auth:*)",
|
"Bash(npx @convex-dev/auth:*)",
|
||||||
"Bash(dir:*)"
|
"Bash(dir:*)",
|
||||||
|
"Bash(npx vite build:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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" />
|
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>discord</title>
|
<title>discord</title>
|
||||||
<script type="module" crossorigin src="./assets/index-B1qeTixj.js"></script>
|
<script type="module" crossorigin src="./assets/index-DXKRzYO-.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-D1fin5Al.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-D1fin5Al.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
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 React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||||
import { useQuery, useMutation, useConvex } from 'convex/react';
|
import { useQuery, usePaginatedQuery, useMutation, useConvex } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
@@ -27,14 +27,71 @@ const heartIcon = getEmojiUrl('symbols', 'heart');
|
|||||||
const thumbsupIcon = getEmojiUrl('people', 'thumbsup');
|
const thumbsupIcon = getEmojiUrl('people', 'thumbsup');
|
||||||
import GifPicker from './GifPicker';
|
import GifPicker from './GifPicker';
|
||||||
|
|
||||||
// Cache for link metadata to prevent pop-in
|
|
||||||
const metadataCache = new Map();
|
const metadataCache = new Map();
|
||||||
|
const attachmentCache = new Map();
|
||||||
|
|
||||||
|
const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)';
|
||||||
|
const ICON_COLOR_DANGER = 'color-mix(in oklab, hsl(1.353 calc(1*82.609%) 68.431% /1) 100%, #000 0%)';
|
||||||
|
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||||
|
|
||||||
|
const fromHexString = (hexString) =>
|
||||||
|
new Uint8Array(hexString.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)));
|
||||||
|
|
||||||
|
const toHexString = (bytes) =>
|
||||||
|
bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
|
||||||
|
|
||||||
|
const getUserColor = (name) => {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); }
|
||||||
|
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getReactionIcon = (name) => {
|
||||||
|
switch (name) {
|
||||||
|
case 'thumbsup': return thumbsupIcon;
|
||||||
|
case 'heart': return heartIcon;
|
||||||
|
case 'fire': return fireIcon;
|
||||||
|
default: return heartIcon;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractUrls = (text) => {
|
||||||
|
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||||
|
return text.match(urlRegex) || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getYouTubeId = (link) => {
|
||||||
|
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||||
|
const match = link.match(regExp);
|
||||||
|
return (match && match[2].length === 11) ? match[2] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatMentions = (text) => {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.replace(/@(\w+)/g, '[@$1](mention://$1)');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatEmojis = (text) => {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.replace(/:([a-zA-Z0-9_]+):/g, (match, name) => {
|
||||||
|
const emoji = AllEmojis.find(e => e.name === name);
|
||||||
|
return emoji ? `` : match;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNewDay = (current, previous) => {
|
||||||
|
if (!previous) return true;
|
||||||
|
return current.getDate() !== previous.getDate()
|
||||||
|
|| current.getMonth() !== previous.getMonth()
|
||||||
|
|| current.getFullYear() !== previous.getFullYear();
|
||||||
|
};
|
||||||
|
|
||||||
// Extracted LinkPreview to prevent re-renders on ChatArea updates
|
|
||||||
const LinkPreview = ({ url }) => {
|
const LinkPreview = ({ url }) => {
|
||||||
const [metadata, setMetadata] = useState(metadataCache.get(url) || null);
|
const [metadata, setMetadata] = useState(metadataCache.get(url) || null);
|
||||||
const [loading, setLoading] = useState(!metadataCache.has(url));
|
const [loading, setLoading] = useState(!metadataCache.has(url));
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [showControls, setShowControls] = useState(false);
|
||||||
|
const videoRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (metadataCache.has(url)) {
|
if (metadataCache.has(url)) {
|
||||||
@@ -61,15 +118,6 @@ const LinkPreview = ({ url }) => {
|
|||||||
return () => { isMounted = false; };
|
return () => { isMounted = false; };
|
||||||
}, [url]);
|
}, [url]);
|
||||||
|
|
||||||
const [showControls, setShowControls] = useState(false);
|
|
||||||
const videoRef = useRef(null);
|
|
||||||
|
|
||||||
const getYouTubeId = (link) => {
|
|
||||||
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
|
||||||
const match = link.match(regExp);
|
|
||||||
return (match && match[2].length === 11) ? match[2] : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const videoId = getYouTubeId(url);
|
const videoId = getYouTubeId(url);
|
||||||
const isYouTube = !!videoId;
|
const isYouTube = !!videoId;
|
||||||
|
|
||||||
@@ -78,9 +126,7 @@ const LinkPreview = ({ url }) => {
|
|||||||
if (metadata.video && !isYouTube) {
|
if (metadata.video && !isYouTube) {
|
||||||
const handlePlayClick = () => {
|
const handlePlayClick = () => {
|
||||||
setShowControls(true);
|
setShowControls(true);
|
||||||
if (videoRef.current) {
|
if (videoRef.current) videoRef.current.play();
|
||||||
videoRef.current.play();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -140,14 +186,6 @@ const LinkPreview = ({ url }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fromHexString = (hexString) =>
|
|
||||||
new Uint8Array(hexString.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)));
|
|
||||||
|
|
||||||
const toHexString = (bytes) =>
|
|
||||||
bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
|
|
||||||
|
|
||||||
const attachmentCache = new Map();
|
|
||||||
|
|
||||||
const Attachment = ({ metadata, onLoad, onImageClick }) => {
|
const Attachment = ({ metadata, onLoad, onImageClick }) => {
|
||||||
const [url, setUrl] = useState(attachmentCache.get(metadata.url) || null);
|
const [url, setUrl] = useState(attachmentCache.get(metadata.url) || null);
|
||||||
const [loading, setLoading] = useState(!attachmentCache.has(metadata.url));
|
const [loading, setLoading] = useState(!attachmentCache.has(metadata.url));
|
||||||
@@ -243,24 +281,30 @@ const PendingFilePreview = ({ file, onRemove }) => {
|
|||||||
<ColoredIcon src={icon} color="#fff" size="16px" />
|
<ColoredIcon src={icon} color="#fff" size="16px" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
return (
|
|
||||||
<div style={{ display: 'inline-flex', flexDirection: 'column', marginRight: '10px' }}>
|
let previewContent;
|
||||||
<div style={{ position: 'relative', width: '200px', height: '200px', borderRadius: '8px', backgroundColor: '#2f3136', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
|
if (preview && isVideo) {
|
||||||
{preview ? (
|
previewContent = (
|
||||||
isVideo ? (
|
|
||||||
<>
|
<>
|
||||||
<video src={preview} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
<video src={preview} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', fontSize: '24px', color: 'white', textShadow: '0 1px 4px rgba(0,0,0,0.7)', pointerEvents: 'none' }}>▶</div>
|
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', fontSize: '24px', color: 'white', textShadow: '0 1px 4px rgba(0,0,0,0.7)', pointerEvents: 'none' }}>▶</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
);
|
||||||
<img src={preview} alt="Preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
} else if (preview) {
|
||||||
)
|
previewContent = <img src={preview} alt="Preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />;
|
||||||
) : (
|
} else {
|
||||||
|
previewContent = (
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<div style={{ fontSize: '24px' }}>📄</div>
|
<div style={{ fontSize: '24px' }}>📄</div>
|
||||||
<div style={{ fontSize: '10px', color: '#b9bbbe', marginTop: '4px', maxWidth: '90px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</div>
|
<div style={{ fontSize: '10px', color: '#b9bbbe', marginTop: '4px', maxWidth: '90px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'inline-flex', flexDirection: 'column', marginRight: '10px' }}>
|
||||||
|
<div style={{ position: 'relative', width: '200px', height: '200px', borderRadius: '8px', backgroundColor: '#2f3136', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
|
||||||
|
{previewContent}
|
||||||
<div style={{ position: 'absolute', top: '4px', right: '4px', display: 'flex', gap: '4px', padding: '4px' }}>
|
<div style={{ position: 'absolute', top: '4px', right: '4px', display: 'flex', gap: '4px', padding: '4px' }}>
|
||||||
<ActionButton icon={SpoilerIcon} onClick={() => {}} />
|
<ActionButton icon={SpoilerIcon} onClick={() => {}} />
|
||||||
<ActionButton icon={EditIcon} onClick={() => {}} />
|
<ActionButton icon={EditIcon} onClick={() => {}} />
|
||||||
@@ -307,10 +351,10 @@ const MessageToolbar = ({ onAddReaction, onEdit, onReply, onMore, isOwner }) =>
|
|||||||
<IconButton onClick={() => onAddReaction('heart')} title="Add Reaction" emoji={<ColoredIcon src={heartIcon} size="20px" />} />
|
<IconButton onClick={() => onAddReaction('heart')} title="Add Reaction" emoji={<ColoredIcon src={heartIcon} size="20px" />} />
|
||||||
<IconButton onClick={() => onAddReaction('fire')} title="Add Reaction" emoji={<ColoredIcon src={fireIcon} size="20px" />} />
|
<IconButton onClick={() => onAddReaction('fire')} title="Add Reaction" emoji={<ColoredIcon src={fireIcon} size="20px" />} />
|
||||||
<div style={{ width: '1px', height: '24px', margin: '2px 4px', backgroundColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%)' }}></div>
|
<div style={{ width: '1px', height: '24px', margin: '2px 4px', backgroundColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.12156862745098039) 100%,hsl(0 0% 0% /0.12156862745098039) 0%)' }}></div>
|
||||||
<IconButton onClick={() => onAddReaction(null)} title="Add Reaction" emoji={<ColoredIcon src={EmojieIcon} color="color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)" size="20px" />} />
|
<IconButton onClick={() => onAddReaction(null)} title="Add Reaction" emoji={<ColoredIcon src={EmojieIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
||||||
{isOwner && <IconButton onClick={onEdit} title="Edit" emoji={<ColoredIcon src={EditIcon} color="color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)" size="20px" />} />}
|
{isOwner && <IconButton onClick={onEdit} title="Edit" emoji={<ColoredIcon src={EditIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />}
|
||||||
<IconButton onClick={onReply} title="Reply" emoji={<ColoredIcon src={ReplyIcon} color="color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)" size="20px" />} />
|
<IconButton onClick={onReply} title="Reply" emoji={<ColoredIcon src={ReplyIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
||||||
<IconButton onClick={onMore} title="More" emoji={<ColoredIcon src={MoreIcon} color="color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)" size="20px" />} />
|
<IconButton onClick={onMore} title="More" emoji={<ColoredIcon src={MoreIcon} color={ICON_COLOR_DEFAULT} size="20px" />} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -325,7 +369,7 @@ const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner }) => {
|
|||||||
const [pos, setPos] = useState({ top: y, left: x });
|
const [pos, setPos] = useState({ top: y, left: x });
|
||||||
useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); return () => window.removeEventListener('click', h); }, [onClose]);
|
useEffect(() => { const h = () => onClose(); window.addEventListener('click', h); return () => window.removeEventListener('click', h); }, [onClose]);
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (menuRef.current) {
|
if (!menuRef.current) return;
|
||||||
const rect = menuRef.current.getBoundingClientRect();
|
const rect = menuRef.current.getBoundingClientRect();
|
||||||
let newTop = y, newLeft = x;
|
let newTop = y, newLeft = x;
|
||||||
if (x + rect.width > window.innerWidth) newLeft = x - rect.width;
|
if (x + rect.width > window.innerWidth) newLeft = x - rect.width;
|
||||||
@@ -333,11 +377,10 @@ const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner }) => {
|
|||||||
if (newLeft < 0) newLeft = 10;
|
if (newLeft < 0) newLeft = 10;
|
||||||
if (newTop < 0) newTop = 10;
|
if (newTop < 0) newTop = 10;
|
||||||
setPos({ top: newTop, left: newLeft });
|
setPos({ top: newTop, left: newLeft });
|
||||||
}
|
|
||||||
}, [x, y]);
|
}, [x, y]);
|
||||||
|
|
||||||
const MenuItem = ({ label, iconSrc, iconColor, onClick, danger }) => (
|
const MenuItem = ({ label, iconSrc, iconColor, onClick, danger }) => (
|
||||||
<div onClick={(e) => { e.stopPropagation(); onClick(); onClose(); }} style={{ display: 'flex', alignItems: 'center', padding: '10px 12px', cursor: 'pointer', fontSize: '14px', color: danger ? 'color-mix(in oklab, hsl(1.353 calc(1*82.609%) 68.431% /1) 100%, #000 0%)' : '#dcddde', justifyContent: 'space-between', whiteSpace: 'nowrap' }} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = danger ? 'color-mix(in oklab,hsl(355.636 calc(1*64.706%) 50% /0.0784313725490196) 100%,hsl(0 0% 0% /0.0784313725490196) 0%)' : 'hsl(240 calc(1*4%) 60.784% /0.0784313725490196)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
|
<div onClick={(e) => { e.stopPropagation(); onClick(); onClose(); }} style={{ display: 'flex', alignItems: 'center', padding: '10px 12px', cursor: 'pointer', fontSize: '14px', color: danger ? ICON_COLOR_DANGER : '#dcddde', justifyContent: 'space-between', whiteSpace: 'nowrap' }} onMouseEnter={(e) => e.currentTarget.style.backgroundColor = danger ? 'color-mix(in oklab,hsl(355.636 calc(1*64.706%) 50% /0.0784313725490196) 100%,hsl(0 0% 0% /0.0784313725490196) 0%)' : 'hsl(240 calc(1*4%) 60.784% /0.0784313725490196)'} onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<div style={{ marginLeft: '12px' }}><ColoredIcon src={iconSrc} color={iconColor} size="18px" /></div>
|
<div style={{ marginLeft: '12px' }}><ColoredIcon src={iconSrc} color={iconColor} size="18px" /></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,13 +388,13 @@ const MessageContextMenu = ({ x, y, onInteract, onClose, isOwner }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={menuRef} style={{ position: 'fixed', top: pos.top, left: pos.left, backgroundColor: '#18191c', borderRadius: '4px', boxShadow: '0 8px 16px rgba(0,0,0,0.24)', zIndex: 9999, minWidth: '188px', padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: '2px' }} onClick={(e) => e.stopPropagation()}>
|
<div ref={menuRef} style={{ position: 'fixed', top: pos.top, left: pos.left, backgroundColor: '#18191c', borderRadius: '4px', boxShadow: '0 8px 16px rgba(0,0,0,0.24)', zIndex: 9999, minWidth: '188px', padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: '2px' }} onClick={(e) => e.stopPropagation()}>
|
||||||
<MenuItem label="Add Reaction" iconSrc={EmojieIcon} iconColor='color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)' onClick={() => onInteract('reaction')} />
|
<MenuItem label="Add Reaction" iconSrc={EmojieIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('reaction')} />
|
||||||
{isOwner && <MenuItem label="Edit Message" iconSrc={EditIcon} iconColor='color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)' onClick={() => onInteract('edit')} />}
|
{isOwner && <MenuItem label="Edit Message" iconSrc={EditIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('edit')} />}
|
||||||
<MenuItem label="Reply" iconSrc={ReplyIcon} iconColor='color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)' onClick={() => onInteract('reply')} />
|
<MenuItem label="Reply" iconSrc={ReplyIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('reply')} />
|
||||||
<div style={{ height: '1px', backgroundColor: '#36393f', margin: '4px 0' }} />
|
<div style={{ height: '1px', backgroundColor: '#36393f', margin: '4px 0' }} />
|
||||||
<MenuItem label="Pin Message" iconSrc={PinIcon} iconColor='color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)' onClick={() => onInteract('pin')} />
|
<MenuItem label="Pin Message" iconSrc={PinIcon} iconColor={ICON_COLOR_DEFAULT} onClick={() => onInteract('pin')} />
|
||||||
<div style={{ height: '1px', backgroundColor: '#36393f', margin: '4px 0' }} />
|
<div style={{ height: '1px', backgroundColor: '#36393f', margin: '4px 0' }} />
|
||||||
{isOwner && <MenuItem label="Delete Message" iconSrc={DeleteIcon} iconColor='color-mix(in oklab, hsl(1.353 calc(1*82.609%) 68.431% /1) 100%, #000 0%)' danger onClick={() => onInteract('delete')} />}
|
{isOwner && <MenuItem label="Delete Message" iconSrc={DeleteIcon} iconColor={ICON_COLOR_DANGER} danger onClick={() => onInteract('delete')} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -362,29 +405,75 @@ const ColoredIcon = ({ src, color, size = '24px', style = {} }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const markdownComponents = {
|
||||||
|
a: ({ node, ...props }) => {
|
||||||
|
if (props.href && props.href.startsWith('mention://')) return <span style={{ background: 'color-mix(in oklab,hsl(234.935 calc(1*85.556%) 64.706% /0.23921568627450981) 100%,hsl(0 0% 0% /0.23921568627450981) 0%)', borderRadius: '3px', color: 'color-mix(in oklab, hsl(228.14 calc(1*100%) 83.137% /1) 100%, #000 0%)', fontWeight: 500, padding: '0 2px', cursor: 'default' }} title={props.children}>{props.children}</span>;
|
||||||
|
return <a {...props} onClick={(e) => { e.preventDefault(); window.cryptoAPI.openExternal(props.href); }} style={{ color: '#00b0f4', cursor: 'pointer', textDecoration: 'none' }} onMouseOver={(e) => e.target.style.textDecoration = 'underline'} onMouseOut={(e) => e.target.style.textDecoration = 'none'} />;
|
||||||
|
},
|
||||||
|
code({ node, inline, className, children, ...props }) {
|
||||||
|
const match = /language-(\w+)/.exec(className || '');
|
||||||
|
return !inline && match ? <SyntaxHighlighter style={oneDark} language={match[1]} PreTag="div" {...props}>{String(children).replace(/\n$/, '')}</SyntaxHighlighter> : <code className={className} {...props}>{children}</code>;
|
||||||
|
},
|
||||||
|
p: ({ node, ...props }) => <p style={{ margin: '0 0 6px 0' }} {...props} />,
|
||||||
|
h1: ({ node, ...props }) => <h1 style={{ fontSize: '1.5rem', fontWeight: 700, margin: '12px 0 6px 0', lineHeight: 1.1 }} {...props} />,
|
||||||
|
h2: ({ node, ...props }) => <h2 style={{ fontSize: '1.25rem', fontWeight: 700, margin: '10px 0 6px 0', lineHeight: 1.1 }} {...props} />,
|
||||||
|
h3: ({ node, ...props }) => <h3 style={{ fontSize: '1.1rem', fontWeight: 700, margin: '8px 0 6px 0', lineHeight: 1.1 }} {...props} />,
|
||||||
|
ul: ({ node, ...props }) => <ul style={{ margin: '0 0 6px 0', paddingLeft: '20px' }} {...props} />,
|
||||||
|
ol: ({ node, ...props }) => <ol style={{ margin: '0 0 6px 0', paddingLeft: '20px' }} {...props} />,
|
||||||
|
li: ({ node, ...props }) => <li style={{ margin: '0' }} {...props} />,
|
||||||
|
hr: ({ node, ...props }) => <hr style={{ border: 'none', borderTop: '1px solid #4f545c', margin: '12px 0' }} {...props} />,
|
||||||
|
img: ({ node, alt, src, ...props }) => {
|
||||||
|
if (alt && alt.startsWith(':') && alt.endsWith(':')) {
|
||||||
|
return <img src={src} alt={alt} style={{ width: '22px', height: '22px', verticalAlign: 'bottom', margin: '0 1px', display: 'inline' }} />;
|
||||||
|
}
|
||||||
|
return <img alt={alt} src={src} {...props} />;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseAttachment = (content) => {
|
||||||
|
if (!content || !content.startsWith('{')) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
return parsed.type === 'attachment' ? parsed : null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const ChatArea = ({ channelId, channelName, username, channelKey, userId: currentUserId }) => {
|
const ChatArea = ({ channelId, channelName, username, channelKey, userId: currentUserId }) => {
|
||||||
const [decryptedMessages, setDecryptedMessages] = useState([]);
|
const [decryptedMessages, setDecryptedMessages] = useState([]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const messagesEndRef = useRef(null);
|
|
||||||
const messagesContainerRef = useRef(null);
|
|
||||||
const inputDivRef = useRef(null);
|
|
||||||
const [zoomedImage, setZoomedImage] = useState(null);
|
const [zoomedImage, setZoomedImage] = useState(null);
|
||||||
const [showGifPicker, setShowGifPicker] = useState(false);
|
const [pickerTab, setPickerTab] = useState(null);
|
||||||
const [pickerActiveTab, setPickerActiveTab] = useState('GIFs');
|
|
||||||
const savedRangeRef = useRef(null);
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [pendingFiles, setPendingFiles] = useState([]);
|
const [pendingFiles, setPendingFiles] = useState([]);
|
||||||
const [hasImages, setHasImages] = useState(false);
|
const [hasImages, setHasImages] = useState(false);
|
||||||
const [isMultiline, setIsMultiline] = useState(false);
|
const [isMultiline, setIsMultiline] = useState(false);
|
||||||
const [hoveredMessageId, setHoveredMessageId] = useState(null);
|
const [hoveredMessageId, setHoveredMessageId] = useState(null);
|
||||||
const [contextMenu, setContextMenu] = useState(null);
|
const [contextMenu, setContextMenu] = useState(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const messagesEndRef = useRef(null);
|
||||||
|
const messagesContainerRef = useRef(null);
|
||||||
|
const inputDivRef = useRef(null);
|
||||||
|
const savedRangeRef = useRef(null);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
const typingTimeoutRef = useRef(null);
|
||||||
|
const lastTypingEmitRef = useRef(0);
|
||||||
|
const decryptionCacheRef = useRef(new Map());
|
||||||
|
const isInitialLoadRef = useRef(true);
|
||||||
|
const prevScrollHeightRef = useRef(0);
|
||||||
|
const isLoadingMoreRef = useRef(false);
|
||||||
|
const userSentMessageRef = useRef(false);
|
||||||
|
const topSentinelRef = useRef(null);
|
||||||
|
const prevResultsLengthRef = useRef(0);
|
||||||
|
|
||||||
const convex = useConvex();
|
const convex = useConvex();
|
||||||
|
|
||||||
// Convex reactive queries - replaces socket.io listeners!
|
const { results: rawMessages, status, loadMore } = usePaginatedQuery(
|
||||||
const rawMessages = useQuery(
|
|
||||||
api.messages.list,
|
api.messages.list,
|
||||||
channelId ? { channelId, userId: currentUserId || undefined } : "skip"
|
channelId ? { channelId, userId: currentUserId || undefined } : "skip",
|
||||||
|
{ initialNumItems: 50 }
|
||||||
);
|
);
|
||||||
|
|
||||||
const typingData = useQuery(
|
const typingData = useQuery(
|
||||||
@@ -392,32 +481,20 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
channelId ? { channelId } : "skip"
|
channelId ? { channelId } : "skip"
|
||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
// Convex mutations
|
|
||||||
const sendMessageMutation = useMutation(api.messages.send);
|
const sendMessageMutation = useMutation(api.messages.send);
|
||||||
const addReaction = useMutation(api.reactions.add);
|
const addReaction = useMutation(api.reactions.add);
|
||||||
const removeReaction = useMutation(api.reactions.remove);
|
const removeReaction = useMutation(api.reactions.remove);
|
||||||
const startTypingMutation = useMutation(api.typing.startTyping);
|
const startTypingMutation = useMutation(api.typing.startTyping);
|
||||||
const stopTypingMutation = useMutation(api.typing.stopTyping);
|
const stopTypingMutation = useMutation(api.typing.stopTyping);
|
||||||
|
|
||||||
const typingTimeoutRef = useRef(null);
|
const showGifPicker = pickerTab !== null;
|
||||||
const lastTypingEmitRef = useRef(0);
|
|
||||||
|
|
||||||
// Close GIF picker when clicking outside
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = () => { if (showGifPicker) setShowGifPicker(false); };
|
const handleClickOutside = () => { if (showGifPicker) setPickerTab(null); };
|
||||||
window.addEventListener('click', handleClickOutside);
|
window.addEventListener('click', handleClickOutside);
|
||||||
return () => window.removeEventListener('click', handleClickOutside);
|
return () => window.removeEventListener('click', handleClickOutside);
|
||||||
}, [showGifPicker]);
|
}, [showGifPicker]);
|
||||||
|
|
||||||
const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)';
|
|
||||||
|
|
||||||
const getUserColor = (username) => {
|
|
||||||
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < username.length; i++) { hash = username.charCodeAt(i) + ((hash << 5) - hash); }
|
|
||||||
return colors[Math.abs(hash) % colors.length];
|
|
||||||
};
|
|
||||||
|
|
||||||
const decryptMessage = async (msg) => {
|
const decryptMessage = async (msg) => {
|
||||||
try {
|
try {
|
||||||
const TAG_LENGTH = 32;
|
const TAG_LENGTH = 32;
|
||||||
@@ -432,36 +509,60 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractUrls = (text) => { const urlRegex = /(https?:\/\/[^\s]+)/g; return text.match(urlRegex) || []; };
|
|
||||||
|
|
||||||
const verifyMessage = async (msg) => {
|
const verifyMessage = async (msg) => {
|
||||||
if (!msg.signature || !msg.public_signing_key) return false;
|
if (!msg.signature || !msg.public_signing_key) return false;
|
||||||
try { return await window.cryptoAPI.verifySignature(msg.public_signing_key, msg.ciphertext, msg.signature); }
|
try { return await window.cryptoAPI.verifySignature(msg.public_signing_key, msg.ciphertext, msg.signature); }
|
||||||
catch (e) { console.error('Verification error for msg:', msg.id, e); return false; }
|
catch (e) { console.error('Verification error for msg:', msg.id, e); return false; }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Decrypt messages when raw messages change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!rawMessages) return;
|
if (!rawMessages || rawMessages.length === 0) {
|
||||||
|
setDecryptedMessages([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = decryptionCacheRef.current;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
const processMessages = async () => {
|
const processMessages = async () => {
|
||||||
const processed = await Promise.all(rawMessages.map(async (msg) => {
|
// Decrypt only messages not already in cache
|
||||||
|
const needsDecryption = rawMessages.filter(msg => !cache.has(msg.id));
|
||||||
|
if (needsDecryption.length > 0) {
|
||||||
|
await Promise.all(needsDecryption.map(async (msg) => {
|
||||||
const content = await decryptMessage(msg);
|
const content = await decryptMessage(msg);
|
||||||
const isVerified = await verifyMessage(msg);
|
const isVerified = await verifyMessage(msg);
|
||||||
return { ...msg, content, isVerified };
|
if (!cancelled) {
|
||||||
|
cache.set(msg.id, { content, isVerified });
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
// Build full chronological array (rawMessages is newest-first, reverse for display)
|
||||||
|
const processed = [...rawMessages].reverse().map(msg => {
|
||||||
|
const cached = cache.get(msg.id);
|
||||||
|
return {
|
||||||
|
...msg,
|
||||||
|
content: cached?.content ?? '[Decrypting...]',
|
||||||
|
isVerified: cached?.isVerified ?? false,
|
||||||
|
};
|
||||||
|
});
|
||||||
setDecryptedMessages(processed);
|
setDecryptedMessages(processed);
|
||||||
};
|
};
|
||||||
|
|
||||||
processMessages();
|
processMessages();
|
||||||
|
return () => { cancelled = true; };
|
||||||
}, [rawMessages, channelKey]);
|
}, [rawMessages, channelKey]);
|
||||||
|
|
||||||
// Clear messages on channel change
|
// Clear decryption cache and reset scroll state on channel/key change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
decryptionCacheRef.current.clear();
|
||||||
setDecryptedMessages([]);
|
setDecryptedMessages([]);
|
||||||
}, [channelId]);
|
isInitialLoadRef.current = true;
|
||||||
|
prevResultsLengthRef.current = 0;
|
||||||
|
}, [channelId, channelKey]);
|
||||||
|
|
||||||
// Filter typing users (exclude self)
|
|
||||||
const typingUsers = typingData.filter(t => t.username !== username);
|
const typingUsers = typingData.filter(t => t.username !== username);
|
||||||
|
|
||||||
const scrollToBottom = useCallback((force = false) => {
|
const scrollToBottom = useCallback((force = false) => {
|
||||||
@@ -475,14 +576,75 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
// Scroll management via useLayoutEffect (fires before paint)
|
||||||
if (decryptedMessages.length > 0) {
|
useLayoutEffect(() => {
|
||||||
const lastMsg = decryptedMessages[decryptedMessages.length - 1];
|
const container = messagesContainerRef.current;
|
||||||
scrollToBottom(lastMsg.username === username);
|
if (!container || decryptedMessages.length === 0) return;
|
||||||
}
|
|
||||||
}, [decryptedMessages, username, scrollToBottom]);
|
|
||||||
|
|
||||||
const fileInputRef = useRef(null);
|
// Initial load — instant scroll to bottom (no animation)
|
||||||
|
if (isInitialLoadRef.current) {
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
isInitialLoadRef.current = false;
|
||||||
|
prevResultsLengthRef.current = rawMessages?.length || 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load more (older messages prepended) — preserve scroll position
|
||||||
|
if (isLoadingMoreRef.current) {
|
||||||
|
const newScrollHeight = container.scrollHeight;
|
||||||
|
const heightDifference = newScrollHeight - prevScrollHeightRef.current;
|
||||||
|
container.scrollTop += heightDifference;
|
||||||
|
isLoadingMoreRef.current = false;
|
||||||
|
prevResultsLengthRef.current = rawMessages?.length || 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User sent a message — force scroll to bottom
|
||||||
|
if (userSentMessageRef.current) {
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
userSentMessageRef.current = false;
|
||||||
|
prevResultsLengthRef.current = rawMessages?.length || 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real-time new message — auto-scroll if near bottom
|
||||||
|
const currentLen = rawMessages?.length || 0;
|
||||||
|
const prevLen = prevResultsLengthRef.current;
|
||||||
|
if (currentLen > prevLen && (currentLen - prevLen) <= 5) {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||||
|
if (scrollHeight - scrollTop - clientHeight < 300) {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prevResultsLengthRef.current = currentLen;
|
||||||
|
}, [decryptedMessages, rawMessages?.length]);
|
||||||
|
|
||||||
|
// IntersectionObserver to trigger loadMore when scrolling near the top
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = topSentinelRef.current;
|
||||||
|
const container = messagesContainerRef.current;
|
||||||
|
if (!sentinel || !container) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && status === 'CanLoadMore') {
|
||||||
|
prevScrollHeightRef.current = container.scrollHeight;
|
||||||
|
isLoadingMoreRef.current = true;
|
||||||
|
loadMore(50);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root: container, rootMargin: '200px 0px 0px 0px', threshold: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(sentinel);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [status, loadMore]);
|
||||||
|
|
||||||
|
const saveSelection = () => {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange();
|
||||||
|
};
|
||||||
|
|
||||||
const insertEmoji = (emoji) => {
|
const insertEmoji = (emoji) => {
|
||||||
if (!inputDivRef.current) return;
|
if (!inputDivRef.current) return;
|
||||||
@@ -508,13 +670,16 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
if (!selection.rangeCount) return;
|
if (!selection.rangeCount) return;
|
||||||
const range = selection.getRangeAt(0);
|
const range = selection.getRangeAt(0);
|
||||||
const node = range.startContainer;
|
const node = range.startContainer;
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
if (node.nodeType !== Node.TEXT_NODE) return;
|
||||||
|
|
||||||
const content = node.textContent.substring(0, range.startOffset);
|
const content = node.textContent.substring(0, range.startOffset);
|
||||||
const match = content.match(/:([a-zA-Z0-9_]+):$/);
|
const match = content.match(/:([a-zA-Z0-9_]+):$/);
|
||||||
if (match) {
|
if (!match) return;
|
||||||
|
|
||||||
const name = match[1];
|
const name = match[1];
|
||||||
const emoji = AllEmojis.find(e => e.name === name);
|
const emoji = AllEmojis.find(e => e.name === name);
|
||||||
if (emoji) {
|
if (!emoji) return;
|
||||||
|
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.src = emoji.src; img.alt = `:${name}:`; img.className = "inline-emoji";
|
img.src = emoji.src; img.alt = `:${name}:`; img.className = "inline-emoji";
|
||||||
img.style.width = "22px"; img.style.height = "22px"; img.style.verticalAlign = "bottom"; img.style.margin = "0 1px"; img.contentEditable = "false";
|
img.style.width = "22px"; img.style.height = "22px"; img.style.verticalAlign = "bottom"; img.style.margin = "0 1px"; img.contentEditable = "false";
|
||||||
@@ -525,13 +690,8 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
if (node.nextSibling) { node.parentNode.insertBefore(img, node.nextSibling); node.parentNode.insertBefore(afterNode, img.nextSibling); }
|
if (node.nextSibling) { node.parentNode.insertBefore(img, node.nextSibling); node.parentNode.insertBefore(afterNode, img.nextSibling); }
|
||||||
else { node.parentNode.appendChild(img); node.parentNode.appendChild(afterNode); }
|
else { node.parentNode.appendChild(img); node.parentNode.appendChild(afterNode); }
|
||||||
const newRange = document.createRange(); newRange.setStart(afterNode, 0); newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange);
|
const newRange = document.createRange(); newRange.setStart(afterNode, 0); newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
|
|
||||||
const processFile = (file) => { setPendingFiles(prev => [...prev, file]); };
|
const processFile = (file) => { setPendingFiles(prev => [...prev, file]); };
|
||||||
const handleFileSelect = (e) => { if (e.target.files && e.target.files.length > 0) Array.from(e.target.files).forEach(processFile); };
|
const handleFileSelect = (e) => { if (e.target.files && e.target.files.length > 0) Array.from(e.target.files).forEach(processFile); };
|
||||||
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); if (!isDragging) setIsDragging(true); };
|
const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); if (!isDragging) setIsDragging(true); };
|
||||||
@@ -547,12 +707,10 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
const encryptedBytes = fromHexString(encryptedHex);
|
const encryptedBytes = fromHexString(encryptedHex);
|
||||||
const blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
const blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
||||||
|
|
||||||
// Upload to Convex storage
|
|
||||||
const uploadUrl = await convex.mutation(api.files.generateUploadUrl, {});
|
const uploadUrl = await convex.mutation(api.files.generateUploadUrl, {});
|
||||||
const uploadRes = await fetch(uploadUrl, { method: 'POST', headers: { 'Content-Type': blob.type }, body: blob });
|
const uploadRes = await fetch(uploadUrl, { method: 'POST', headers: { 'Content-Type': blob.type }, body: blob });
|
||||||
const { storageId } = await uploadRes.json();
|
const { storageId } = await uploadRes.json();
|
||||||
|
|
||||||
// Get the file URL
|
|
||||||
const fileUrl = await convex.query(api.files.getFileUrl, { storageId });
|
const fileUrl = await convex.query(api.files.getFileUrl, { storageId });
|
||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
@@ -590,6 +748,14 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearTypingState = () => {
|
||||||
|
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
||||||
|
if (currentUserId && channelId) {
|
||||||
|
stopTypingMutation({ channelId, userId: currentUserId }).catch(() => {});
|
||||||
|
}
|
||||||
|
lastTypingEmitRef.current = 0;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSend = async (e) => {
|
const handleSend = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let messageContent = '';
|
let messageContent = '';
|
||||||
@@ -605,6 +771,7 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
if (!messageContent && pendingFiles.length === 0) return;
|
if (!messageContent && pendingFiles.length === 0) return;
|
||||||
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
userSentMessageRef.current = true;
|
||||||
try {
|
try {
|
||||||
for (const file of pendingFiles) await uploadAndSendFile(file);
|
for (const file of pendingFiles) await uploadAndSendFile(file);
|
||||||
setPendingFiles([]);
|
setPendingFiles([]);
|
||||||
@@ -612,11 +779,7 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
await sendMessage(messageContent);
|
await sendMessage(messageContent);
|
||||||
if (inputDivRef.current) inputDivRef.current.innerHTML = '';
|
if (inputDivRef.current) inputDivRef.current.innerHTML = '';
|
||||||
setInput(''); setHasImages(false);
|
setInput(''); setHasImages(false);
|
||||||
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
clearTypingState();
|
||||||
if (currentUserId && channelId) {
|
|
||||||
stopTypingMutation({ channelId, userId: currentUserId }).catch(() => {});
|
|
||||||
}
|
|
||||||
lastTypingEmitRef.current = 0;
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error sending message/files:", err);
|
console.error("Error sending message/files:", err);
|
||||||
@@ -646,17 +809,6 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatMentions = (text) => { if (!text) return ''; return text.replace(/@(\w+)/g, '[@$1](mention://$1)'); };
|
|
||||||
|
|
||||||
const formatEmojis = (text) => {
|
|
||||||
if (!text) return '';
|
|
||||||
return text.replace(/:([a-zA-Z0-9_]+):/g, (match, name) => {
|
|
||||||
const emoji = AllEmojis.find(e => e.name === name);
|
|
||||||
if (emoji) return ``;
|
|
||||||
return match;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReactionClick = async (messageId, emoji, hasMyReaction) => {
|
const handleReactionClick = async (messageId, emoji, hasMyReaction) => {
|
||||||
if (!currentUserId) return;
|
if (!currentUserId) return;
|
||||||
if (hasMyReaction) {
|
if (hasMyReaction) {
|
||||||
@@ -666,95 +818,124 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const togglePicker = (tab) => {
|
||||||
|
if (pickerTab === tab) {
|
||||||
|
setPickerTab(null);
|
||||||
|
} else {
|
||||||
|
setPickerTab(tab);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMessageContent = (msg) => {
|
||||||
|
const attachmentMetadata = parseAttachment(msg.content);
|
||||||
|
if (attachmentMetadata) {
|
||||||
|
return <Attachment metadata={attachmentMetadata} onLoad={scrollToBottom} onImageClick={setZoomedImage} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls = extractUrls(msg.content);
|
||||||
|
const isOnlyUrl = urls.length === 1 && msg.content.trim() === urls[0];
|
||||||
|
const isGif = isOnlyUrl && (urls[0].includes('tenor.com') || urls[0].includes('giphy.com') || urls[0].endsWith('.gif'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!isGif && (
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={(url) => url} components={markdownComponents}>
|
||||||
|
{formatEmojis(formatMentions(msg.content))}
|
||||||
|
</ReactMarkdown>
|
||||||
|
)}
|
||||||
|
{urls.map((url, i) => <LinkPreview key={i} url={url} />)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderReactions = (msg) => {
|
||||||
|
if (!msg.reactions || Object.keys(msg.reactions).length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}>
|
||||||
|
{Object.entries(msg.reactions).map(([emojiName, data]) => (
|
||||||
|
<div key={emojiName} onClick={() => handleReactionClick(msg.id, emojiName, data.me)} style={{ display: 'flex', alignItems: 'center', backgroundColor: data.me ? 'rgba(88, 101, 242, 0.15)' : '#2f3136', border: data.me ? '1px solid #5865F2' : '1px solid transparent', borderRadius: '8px', padding: '2px 6px', cursor: 'pointer', gap: '4px' }}>
|
||||||
|
<ColoredIcon src={getReactionIcon(emojiName)} size="16px" color={data.me ? null : '#b9bbbe'} />
|
||||||
|
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : '#b9bbbe', fontWeight: 600 }}>{data.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-area" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} style={{ position: 'relative' }}>
|
<div className="chat-area" onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} style={{ position: 'relative' }}>
|
||||||
{isDragging && <DragOverlay />}
|
{isDragging && <DragOverlay />}
|
||||||
<div className="messages-list" ref={messagesContainerRef}>
|
<div className="messages-list" ref={messagesContainerRef}>
|
||||||
<div className="messages-content-wrapper">
|
<div className="messages-content-wrapper">
|
||||||
|
<div ref={topSentinelRef} style={{ height: '1px', width: '100%' }} />
|
||||||
|
{status === 'LoadingMore' && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
|
||||||
|
<div className="loading-spinner" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'Exhausted' && decryptedMessages.length > 0 && (
|
||||||
|
<div className="channel-beginning">
|
||||||
|
<div className="channel-beginning-icon">#</div>
|
||||||
|
<h1 className="channel-beginning-title">Welcome to #{channelName}</h1>
|
||||||
|
<p className="channel-beginning-subtitle">This is the start of the #{channelName} channel.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'LoadingFirstPage' && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flex: 1, padding: '40px 0' }}>
|
||||||
|
<div className="loading-spinner" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{decryptedMessages.map((msg, idx) => {
|
{decryptedMessages.map((msg, idx) => {
|
||||||
const currentDate = new Date(msg.created_at);
|
const currentDate = new Date(msg.created_at);
|
||||||
const previousDate = idx > 0 ? new Date(decryptedMessages[idx - 1].created_at) : null;
|
const previousDate = idx > 0 ? new Date(decryptedMessages[idx - 1].created_at) : null;
|
||||||
const isNewDay = !previousDate || (currentDate.getDate() !== previousDate.getDate() || currentDate.getMonth() !== previousDate.getMonth() || currentDate.getFullYear() !== previousDate.getFullYear());
|
|
||||||
let isAttachment = false;
|
|
||||||
let attachmentMetadata = null;
|
|
||||||
try { if (msg.content.startsWith('{')) { const parsed = JSON.parse(msg.content); if (parsed.type === 'attachment') { isAttachment = true; attachmentMetadata = parsed; } } } catch (e) {}
|
|
||||||
const isMentioned = msg.content && msg.content.includes(`@${username}`);
|
const isMentioned = msg.content && msg.content.includes(`@${username}`);
|
||||||
|
const userColor = getUserColor(msg.username || 'Unknown');
|
||||||
|
const isOwner = msg.username === username;
|
||||||
|
|
||||||
|
const prevMsg = idx > 0 ? decryptedMessages[idx - 1] : null;
|
||||||
|
const isGrouped = prevMsg
|
||||||
|
&& prevMsg.username === msg.username
|
||||||
|
&& !isNewDay(currentDate, previousDate)
|
||||||
|
&& (currentDate - new Date(prevMsg.created_at)) < 60000;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={msg.id || idx}>
|
<React.Fragment key={msg.id || idx}>
|
||||||
{isNewDay && <div className="date-divider"><span>{currentDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}</span></div>}
|
{isNewDay(currentDate, previousDate) && <div className="date-divider"><span>{currentDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}</span></div>}
|
||||||
<div className="message-item" style={isMentioned ? { background: 'color-mix(in oklab,hsl(36.894 calc(1*100%) 31.569% /0.0784313725490196) 100%,hsl(0 0% 0% /0.0784313725490196) 0%)', position: 'relative' } : { position: 'relative' }} onMouseEnter={() => setHoveredMessageId(msg.id)} onMouseLeave={() => setHoveredMessageId(null)} onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner: msg.username === username }); }}>
|
<div className={`message-item${isGrouped ? ' message-grouped' : ''}`} style={isMentioned ? { background: 'color-mix(in oklab,hsl(36.894 calc(1*100%) 31.569% /0.0784313725490196) 100%,hsl(0 0% 0% /0.0784313725490196) 0%)', position: 'relative' } : { position: 'relative' }} onMouseEnter={() => setHoveredMessageId(msg.id)} onMouseLeave={() => setHoveredMessageId(null)} onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, messageId: msg.id, isOwner }); }}>
|
||||||
{isMentioned && <div style={{ background: 'color-mix(in oklab, hsl(34 calc(1*50.847%) 53.725% /1) 100%, #000 0%)', bottom: 0, display: 'block', left: 0, pointerEvents: 'none', position: 'absolute', top: 0, width: '2px' }} />}
|
{isMentioned && <div style={{ background: 'color-mix(in oklab, hsl(34 calc(1*50.847%) 53.725% /1) 100%, #000 0%)', bottom: 0, display: 'block', left: 0, pointerEvents: 'none', position: 'absolute', top: 0, width: '2px' }} />}
|
||||||
|
{isGrouped ? (
|
||||||
|
<div className="message-avatar-wrapper grouped-timestamp-wrapper">
|
||||||
|
<span className="grouped-timestamp">
|
||||||
|
{currentDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="message-avatar-wrapper">
|
<div className="message-avatar-wrapper">
|
||||||
<div className="message-avatar" style={{ backgroundColor: getUserColor(msg.username || 'Unknown') }}>
|
<div className="message-avatar" style={{ backgroundColor: userColor }}>
|
||||||
{(msg.username || '?').substring(0, 1).toUpperCase()}
|
{(msg.username || '?').substring(0, 1).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="message-body">
|
<div className="message-body">
|
||||||
|
{!isGrouped && (
|
||||||
<div className="message-header">
|
<div className="message-header">
|
||||||
<span className="username" style={{ color: getUserColor(msg.username || 'Unknown') }}>{msg.username || 'Unknown'}</span>
|
<span className="username" style={{ color: userColor }}>{msg.username || 'Unknown'}</span>
|
||||||
{!msg.isVerified && <span className="verification-failed" title="Signature Verification Failed!"><svg aria-hidden="true" role="img" width="16" height="16" viewBox="0 0 24 24" fill="var(--danger)"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></svg></span>}
|
{!msg.isVerified && <span className="verification-failed" title="Signature Verification Failed!"><svg aria-hidden="true" role="img" width="16" height="16" viewBox="0 0 24 24" fill="var(--danger)"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></svg></span>}
|
||||||
<span className="timestamp">{currentDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span>
|
<span className="timestamp">{currentDate.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<div className="message-content">
|
<div className="message-content">
|
||||||
{isAttachment ? <Attachment metadata={attachmentMetadata} onLoad={scrollToBottom} onImageClick={setZoomedImage} /> : (
|
{renderMessageContent(msg)}
|
||||||
<>
|
{renderReactions(msg)}
|
||||||
{(() => {
|
|
||||||
const urls = extractUrls(msg.content);
|
|
||||||
const isOnlyUrl = urls.length === 1 && msg.content.trim() === urls[0];
|
|
||||||
const isGif = isOnlyUrl && (urls[0].includes('tenor.com') || urls[0].includes('giphy.com') || urls[0].endsWith('.gif'));
|
|
||||||
if (isGif) return null;
|
|
||||||
return (
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={(url) => url} components={{
|
|
||||||
a: ({ node, ...props }) => {
|
|
||||||
if (props.href && props.href.startsWith('mention://')) return <span style={{ background: 'color-mix(in oklab,hsl(234.935 calc(1*85.556%) 64.706% /0.23921568627450981) 100%,hsl(0 0% 0% /0.23921568627450981) 0%)', borderRadius: '3px', color: 'color-mix(in oklab, hsl(228.14 calc(1*100%) 83.137% /1) 100%, #000 0%)', fontWeight: 500, padding: '0 2px', cursor: 'default' }} title={props.children}>{props.children}</span>;
|
|
||||||
return <a {...props} onClick={(e) => { e.preventDefault(); window.cryptoAPI.openExternal(props.href); }} style={{ color: '#00b0f4', cursor: 'pointer', textDecoration: 'none' }} onMouseOver={(e) => e.target.style.textDecoration = 'underline'} onMouseOut={(e) => e.target.style.textDecoration = 'none'} />;
|
|
||||||
},
|
|
||||||
code({ node, inline, className, children, ...props }) {
|
|
||||||
const match = /language-(\w+)/.exec(className || '');
|
|
||||||
return !inline && match ? <SyntaxHighlighter style={oneDark} language={match[1]} PreTag="div" {...props}>{String(children).replace(/\n$/, '')}</SyntaxHighlighter> : <code className={className} {...props}>{children}</code>;
|
|
||||||
},
|
|
||||||
p: ({ node, ...props }) => <p style={{ margin: '0 0 6px 0' }} {...props} />,
|
|
||||||
h1: ({ node, ...props }) => <h1 style={{ fontSize: '1.5rem', fontWeight: 700, margin: '12px 0 6px 0', lineHeight: 1.1 }} {...props} />,
|
|
||||||
h2: ({ node, ...props }) => <h2 style={{ fontSize: '1.25rem', fontWeight: 700, margin: '10px 0 6px 0', lineHeight: 1.1 }} {...props} />,
|
|
||||||
h3: ({ node, ...props }) => <h3 style={{ fontSize: '1.1rem', fontWeight: 700, margin: '8px 0 6px 0', lineHeight: 1.1 }} {...props} />,
|
|
||||||
ul: ({ node, ...props }) => <ul style={{ margin: '0 0 6px 0', paddingLeft: '20px' }} {...props} />,
|
|
||||||
ol: ({ node, ...props }) => <ol style={{ margin: '0 0 6px 0', paddingLeft: '20px' }} {...props} />,
|
|
||||||
li: ({ node, ...props }) => <li style={{ margin: '0' }} {...props} />,
|
|
||||||
hr: ({ node, ...props }) => <hr style={{ border: 'none', borderTop: '1px solid #4f545c', margin: '12px 0' }} {...props} />,
|
|
||||||
img: ({ node, alt, src, ...props }) => {
|
|
||||||
if (alt && alt.startsWith(':') && alt.endsWith(':')) {
|
|
||||||
return <img src={src} alt={alt} style={{ width: '22px', height: '22px', verticalAlign: 'bottom', margin: '0 1px', display: 'inline' }} />;
|
|
||||||
}
|
|
||||||
return <img alt={alt} src={src} {...props} />;
|
|
||||||
},
|
|
||||||
}}>{formatEmojis(formatMentions(msg.content))}</ReactMarkdown>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
{extractUrls(msg.content).map((url, i) => <LinkPreview key={i} url={url} />)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{msg.reactions && Object.keys(msg.reactions).length > 0 && (
|
|
||||||
<div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}>
|
|
||||||
{Object.entries(msg.reactions).map(([emojiName, data]) => {
|
|
||||||
const getIcon = (name) => { switch(name) { case 'thumbsup': return thumbsupIcon; case 'heart': return heartIcon; case 'fire': return fireIcon; default: return heartIcon; } };
|
|
||||||
return (
|
|
||||||
<div key={emojiName} onClick={() => handleReactionClick(msg.id, emojiName, data.me)} style={{ display: 'flex', alignItems: 'center', backgroundColor: data.me ? 'rgba(88, 101, 242, 0.15)' : '#2f3136', border: data.me ? '1px solid #5865F2' : '1px solid transparent', borderRadius: '8px', padding: '2px 6px', cursor: 'pointer', gap: '4px' }}>
|
|
||||||
<ColoredIcon src={getIcon(emojiName)} size="16px" color={data.me ? null : '#b9bbbe'} />
|
|
||||||
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : '#b9bbbe', fontWeight: 600 }}>{data.count}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{hoveredMessageId === msg.id && (
|
{hoveredMessageId === msg.id && (
|
||||||
<MessageToolbar isOwner={msg.username === username}
|
<MessageToolbar isOwner={isOwner}
|
||||||
onAddReaction={(emoji) => { const emojiName = emoji || 'heart'; addReaction({ messageId: msg.id, userId: currentUserId, emoji: emojiName }); }}
|
onAddReaction={(emoji) => { addReaction({ messageId: msg.id, userId: currentUserId, emoji: emoji || 'heart' }); }}
|
||||||
onEdit={() => console.log('Edit', msg.id)}
|
onEdit={() => console.log('Edit', msg.id)}
|
||||||
onReply={() => console.log('Reply', msg.id)}
|
onReply={() => console.log('Reply', msg.id)}
|
||||||
onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner: msg.username === username }); }}
|
onMore={(e) => { const rect = e.target.getBoundingClientRect(); setContextMenu({ x: rect.left, y: rect.bottom, messageId: msg.id, isOwner }); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -786,16 +967,15 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
{uploading ? <div className="spinner" style={{ width: 24, height: 24, borderRadius: '50%', border: '2px solid #b9bbbe', borderTopColor: 'transparent', animation: 'spin 1s linear infinite' }}></div> : <ColoredIcon src={AddIcon} color={ICON_COLOR_DEFAULT} size="24px" />}
|
{uploading ? <div className="spinner" style={{ width: 24, height: 24, borderRadius: '50%', border: '2px solid #b9bbbe', borderTopColor: 'transparent', animation: 'spin 1s linear infinite' }}></div> : <ColoredIcon src={AddIcon} color={ICON_COLOR_DEFAULT} size="24px" />}
|
||||||
</button>
|
</button>
|
||||||
<div ref={inputDivRef} contentEditable className="chat-input-richtext" role="textbox" aria-multiline="true"
|
<div ref={inputDivRef} contentEditable className="chat-input-richtext" role="textbox" aria-multiline="true"
|
||||||
onBlur={() => { const sel = window.getSelection(); if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange(); }}
|
onBlur={saveSelection}
|
||||||
onMouseUp={() => { const sel = window.getSelection(); if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange(); }}
|
onMouseUp={saveSelection}
|
||||||
onKeyUp={() => { const sel = window.getSelection(); if (sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange(); }}
|
onKeyUp={saveSelection}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
setInput(e.currentTarget.textContent);
|
setInput(e.currentTarget.textContent);
|
||||||
setHasImages(e.currentTarget.querySelectorAll('img').length > 0);
|
setHasImages(e.currentTarget.querySelectorAll('img').length > 0);
|
||||||
const text = e.currentTarget.innerText;
|
const text = e.currentTarget.innerText;
|
||||||
setIsMultiline(text.includes('\n') || e.currentTarget.scrollHeight > 50);
|
setIsMultiline(text.includes('\n') || e.currentTarget.scrollHeight > 50);
|
||||||
checkTypedEmoji();
|
checkTypedEmoji();
|
||||||
// Handle Typing via Convex
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastTypingEmitRef.current > 2000 && currentUserId && channelId) {
|
if (now - lastTypingEmitRef.current > 2000 && currentUserId && channelId) {
|
||||||
startTypingMutation({ channelId, userId: currentUserId, username }).catch(() => {});
|
startTypingMutation({ channelId, userId: currentUserId, username }).catch(() => {});
|
||||||
@@ -812,14 +992,14 @@ const ChatArea = ({ channelId, channelName, username, channelKey, userId: curren
|
|||||||
/>
|
/>
|
||||||
{!input && !hasImages && <div style={{ position: 'absolute', left: '70px', color: '#72767d', pointerEvents: 'none', userSelect: 'none' }}>Message #{channelName || channelId}</div>}
|
{!input && !hasImages && <div style={{ position: 'absolute', left: '70px', color: '#72767d', pointerEvents: 'none', userSelect: 'none' }}>Message #{channelName || channelId}</div>}
|
||||||
<div className="chat-input-icons" style={{ position: 'relative' }}>
|
<div className="chat-input-icons" style={{ position: 'relative' }}>
|
||||||
<button type="button" className="chat-input-icon-btn" title="GIF" onClick={(e) => { e.stopPropagation(); if (showGifPicker && pickerActiveTab === 'GIFs') setShowGifPicker(false); else { setPickerActiveTab('GIFs'); setShowGifPicker(true); } }}>
|
<button type="button" className="chat-input-icon-btn" title="GIF" onClick={(e) => { e.stopPropagation(); togglePicker('GIFs'); }}>
|
||||||
<ColoredIcon src={GifIcon} color={(showGifPicker && pickerActiveTab === 'GIFs') ? '#5865f2' : ICON_COLOR_DEFAULT} size="24px" />
|
<ColoredIcon src={GifIcon} color={pickerTab === 'GIFs' ? '#5865f2' : ICON_COLOR_DEFAULT} size="24px" />
|
||||||
</button>
|
</button>
|
||||||
{showGifPicker && (
|
{showGifPicker && (
|
||||||
<GifPicker onSelect={(data) => { if (typeof data === 'string') { sendMessage(data); setShowGifPicker(false); } else { insertEmoji(data); setShowGifPicker(false); } }} onClose={() => setShowGifPicker(false)} currentTab={pickerActiveTab} onTabChange={setPickerActiveTab} />
|
<GifPicker onSelect={(data) => { if (typeof data === 'string') { sendMessage(data); setPickerTab(null); } else { insertEmoji(data); setPickerTab(null); } }} onClose={() => setPickerTab(null)} currentTab={pickerTab} onTabChange={setPickerTab} />
|
||||||
)}
|
)}
|
||||||
<button type="button" className="chat-input-icon-btn" title="Sticker"><ColoredIcon src={StickerIcon} color={ICON_COLOR_DEFAULT} size="24px" /></button>
|
<button type="button" className="chat-input-icon-btn" title="Sticker"><ColoredIcon src={StickerIcon} color={ICON_COLOR_DEFAULT} size="24px" /></button>
|
||||||
<EmojiButton active={showGifPicker && pickerActiveTab === 'Emoji'} onClick={() => { if (showGifPicker && pickerActiveTab === 'Emoji') setShowGifPicker(false); else { setPickerActiveTab('Emoji'); setShowGifPicker(true); } }} />
|
<EmojiButton active={pickerTab === 'Emoji'} onClick={() => togglePicker('Emoji')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,44 +3,199 @@ import React, { useState, useEffect, useRef } from 'react';
|
|||||||
import { useConvex } from 'convex/react';
|
import { useConvex } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
|
|
||||||
|
const EmojiItem = ({ emoji, onSelect }) => (
|
||||||
|
<div
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })}
|
||||||
|
title={`:${emoji.name}:`}
|
||||||
|
style={{ cursor: 'pointer', padding: '4px', borderRadius: '4px' }}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<img src={emoji.src} alt={emoji.name} style={{ width: '32px', height: '32px' }} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const emojiGridStyle = { display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: '4px' };
|
||||||
|
|
||||||
|
const GifContent = ({ search, results, categories, onSelect, onCategoryClick }) => {
|
||||||
|
if (search || results.length > 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||||
|
{results.map(gif => (
|
||||||
|
<img
|
||||||
|
key={gif.id}
|
||||||
|
src={gif.media_formats?.tinygif?.url || gif.media_formats?.gif?.url}
|
||||||
|
alt={gif.title}
|
||||||
|
style={{ width: '100%', borderRadius: '4px', cursor: 'pointer' }}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => onSelect(gif.media_formats?.gif?.url || gif.src)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{results.length === 0 && <div style={{ color: '#b9bbbe', gridColumn: 'span 2', textAlign: 'center' }}>No GIFs found</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
backgroundImage: 'linear-gradient(to right, #4a90e2, #9013fe)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '20px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: '16px',
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}>
|
||||||
|
Favorites
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||||
|
{categories.map(cat => (
|
||||||
|
<div
|
||||||
|
key={cat.name}
|
||||||
|
onClick={() => onCategoryClick(cat.name)}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
height: '100px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: '#202225'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
src={cat.src}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', opacity: 0.6 }}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0, left: 0, right: 0, bottom: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
textTransform: 'capitalize'
|
||||||
|
}}>
|
||||||
|
{cat.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory }) => {
|
||||||
|
if (search) {
|
||||||
|
const filtered = AllEmojis
|
||||||
|
.filter(e => e.name.toLowerCase().includes(search.toLowerCase().replace(/:/g, '')))
|
||||||
|
.slice(0, 100);
|
||||||
|
return (
|
||||||
|
<div className="emoji-grid" style={{ height: '100%' }}>
|
||||||
|
<div style={emojiGridStyle}>
|
||||||
|
{filtered.map((emoji, idx) => (
|
||||||
|
<EmojiItem key={idx} emoji={emoji} onSelect={onSelect} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="emoji-grid" style={{ height: '100%' }}>
|
||||||
|
{Object.entries(CategorizedEmojis).map(([category, emojis]) => (
|
||||||
|
<div key={category} style={{ marginBottom: '8px' }}>
|
||||||
|
<div
|
||||||
|
onClick={() => toggleCategory(category)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: '8px',
|
||||||
|
padding: '4px',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#b9bbbe"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
style={{
|
||||||
|
marginRight: '8px',
|
||||||
|
transform: collapsedCategories[category] ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||||
|
transition: 'transform 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9"></polyline>
|
||||||
|
</svg>
|
||||||
|
<h3 style={{
|
||||||
|
color: '#b9bbbe',
|
||||||
|
fontSize: '12px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
fontWeight: 700,
|
||||||
|
margin: 0
|
||||||
|
}}>
|
||||||
|
{category}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{!collapsedCategories[category] && (
|
||||||
|
<div style={emojiGridStyle}>
|
||||||
|
{emojis.map((emoji, idx) => (
|
||||||
|
<EmojiItem key={idx} emoji={emoji} onSelect={onSelect} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialCollapsed = Object.fromEntries(
|
||||||
|
Object.keys(CategorizedEmojis).map(cat => [cat, true])
|
||||||
|
);
|
||||||
|
|
||||||
const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) => {
|
const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) => {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState([]);
|
||||||
const [results, setResults] = useState([]);
|
const [results, setResults] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [internalActiveTab, setInternalActiveTab] = useState(initialTab || 'GIFs');
|
const [internalActiveTab, setInternalActiveTab] = useState(initialTab || 'GIFs');
|
||||||
|
const [collapsedCategories, setCollapsedCategories] = useState(initialCollapsed);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
const convex = useConvex();
|
||||||
|
|
||||||
// Resolve effective active tab
|
|
||||||
const activeTab = currentTab !== undefined ? currentTab : internalActiveTab;
|
const activeTab = currentTab !== undefined ? currentTab : internalActiveTab;
|
||||||
const setActiveTab = (tab) => {
|
const setActiveTab = (tab) => {
|
||||||
if (onTabChange) onTabChange(tab);
|
if (onTabChange) onTabChange(tab);
|
||||||
if (currentTab === undefined) setInternalActiveTab(tab);
|
if (currentTab === undefined) setInternalActiveTab(tab);
|
||||||
};
|
};
|
||||||
|
|
||||||
const [emojiCategories, setEmojiCategories] = useState({});
|
|
||||||
const [collapsedCategories, setCollapsedCategories] = useState({});
|
|
||||||
const inputRef = useRef(null);
|
|
||||||
|
|
||||||
const convex = useConvex();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch categories via Convex action
|
|
||||||
convex.action(api.gifs.categories, {})
|
convex.action(api.gifs.categories, {})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.categories) setCategories(data.categories);
|
if (data.categories) setCategories(data.categories);
|
||||||
})
|
})
|
||||||
.catch(err => console.error('Failed to load categories', err));
|
.catch(err => console.error('Failed to load categories', err));
|
||||||
|
|
||||||
// Auto focus
|
|
||||||
if (inputRef.current) inputRef.current.focus();
|
if (inputRef.current) inputRef.current.focus();
|
||||||
|
|
||||||
// Load Emoji categories
|
|
||||||
setEmojiCategories(CategorizedEmojis);
|
|
||||||
|
|
||||||
// Initialize collapsed state (all true)
|
|
||||||
const initialCollapsed = {};
|
|
||||||
Object.keys(CategorizedEmojis).forEach(cat => initialCollapsed[cat] = true);
|
|
||||||
setCollapsedCategories(initialCollapsed);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,10 +219,6 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
|||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}, [search, activeTab]);
|
}, [search, activeTab]);
|
||||||
|
|
||||||
const handleCategoryClick = (categoryName) => {
|
|
||||||
setSearch(categoryName);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleCategory = (categoryName) => {
|
const toggleCategory = (categoryName) => {
|
||||||
setCollapsedCategories(prev => ({
|
setCollapsedCategories(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -161,170 +312,9 @@ const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) =
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ color: '#b9bbbe', textAlign: 'center', padding: '20px' }}>Loading...</div>
|
<div style={{ color: '#b9bbbe', textAlign: 'center', padding: '20px' }}>Loading...</div>
|
||||||
) : activeTab === 'GIFs' ? (
|
) : activeTab === 'GIFs' ? (
|
||||||
// GIF Tab Logic (Search Results OR Categories)
|
<GifContent search={search} results={results} categories={categories} onSelect={onSelect} onCategoryClick={setSearch} />
|
||||||
(search || results.length > 0) ? (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
|
||||||
{results.map(gif => (
|
|
||||||
<img
|
|
||||||
key={gif.id}
|
|
||||||
src={gif.media_formats?.tinygif?.url || gif.media_formats?.gif?.url}
|
|
||||||
alt={gif.title}
|
|
||||||
style={{ width: '100%', borderRadius: '4px', cursor: 'pointer' }}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => onSelect(gif.media_formats?.gif?.url || gif.src)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{results.length === 0 && <div style={{ color: '#b9bbbe', gridColumn: 'span 2', textAlign: 'center' }}>No GIFs found</div>}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
// GIF Categories
|
<EmojiContent search={search} onSelect={onSelect} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} />
|
||||||
<div>
|
|
||||||
<div style={{
|
|
||||||
backgroundImage: 'linear-gradient(to right, #4a90e2, #9013fe)',
|
|
||||||
borderRadius: '4px',
|
|
||||||
padding: '20px',
|
|
||||||
marginBottom: '12px',
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
fontSize: '16px',
|
|
||||||
textAlign: 'center',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}>
|
|
||||||
Favorites
|
|
||||||
</div>
|
|
||||||
{/* Grid of Categories */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
|
||||||
{categories.map(cat => (
|
|
||||||
<div
|
|
||||||
key={cat.name}
|
|
||||||
onClick={() => handleCategoryClick(cat.name)}
|
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
height: '100px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
cursor: 'pointer',
|
|
||||||
backgroundColor: '#202225'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<video
|
|
||||||
src={cat.src}
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover', opacity: 0.6 }}
|
|
||||||
/>
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0, left: 0, right: 0, bottom: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
textTransform: 'capitalize'
|
|
||||||
}}>
|
|
||||||
{cat.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
// Emoji / Other Tabs
|
|
||||||
<div className="emoji-grid" style={{ height: '100%' }}>
|
|
||||||
{search ? (
|
|
||||||
// Emoji Search Results
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: '4px' }}>
|
|
||||||
{AllEmojis.filter(e => e.name.toLowerCase().includes(search.toLowerCase().replace(/:/g, '')))
|
|
||||||
.slice(0, 100)
|
|
||||||
.map((emoji, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })}
|
|
||||||
title={`:${emoji.name}:`}
|
|
||||||
style={{ cursor: 'pointer', padding: '4px', borderRadius: '4px' }}
|
|
||||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'}
|
|
||||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
|
||||||
>
|
|
||||||
<img src={emoji.src} alt={emoji.name} style={{ width: '32px', height: '32px' }} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Emoji Categories
|
|
||||||
Object.entries(emojiCategories).map(([category, emojis]) => (
|
|
||||||
<div key={category} style={{ marginBottom: '8px' }}>
|
|
||||||
<div
|
|
||||||
onClick={() => toggleCategory(category)}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
cursor: 'pointer',
|
|
||||||
marginBottom: '8px',
|
|
||||||
padding: '4px',
|
|
||||||
borderRadius: '4px'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'}
|
|
||||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="12"
|
|
||||||
height="12"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="#b9bbbe"
|
|
||||||
strokeWidth="3"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
style={{
|
|
||||||
marginRight: '8px',
|
|
||||||
transform: collapsedCategories[category] ? 'rotate(-90deg)' : 'rotate(0deg)',
|
|
||||||
transition: 'transform 0.2s'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<polyline points="6 9 12 15 18 9"></polyline>
|
|
||||||
</svg>
|
|
||||||
<h3 style={{
|
|
||||||
color: '#b9bbbe',
|
|
||||||
fontSize: '12px',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
fontWeight: 700,
|
|
||||||
margin: 0
|
|
||||||
}}>
|
|
||||||
{category}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
{!collapsedCategories[category] && (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: '4px' }}>
|
|
||||||
{emojis.map((emoji, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })}
|
|
||||||
title={`:${emoji.name}:`}
|
|
||||||
style={{ cursor: 'pointer', padding: '4px', borderRadius: '4px' }}
|
|
||||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#40444b'}
|
|
||||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={emoji.src}
|
|
||||||
alt={emoji.name}
|
|
||||||
style={{ width: '32px', height: '32px' }}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useQuery, useConvex } from 'convex/react';
|
import { useQuery, useConvex } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
|
|
||||||
@@ -51,13 +51,9 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAssignRole = async (roleId, targetUserId, isAdding) => {
|
const handleAssignRole = async (roleId, targetUserId, isAdding) => {
|
||||||
|
const action = isAdding ? api.roles.assign : api.roles.unassign;
|
||||||
try {
|
try {
|
||||||
if (isAdding) {
|
await convex.mutation(action, { roleId, userId: targetUserId });
|
||||||
await convex.mutation(api.roles.assign, { roleId, userId: targetUserId });
|
|
||||||
} else {
|
|
||||||
await convex.mutation(api.roles.unassign, { roleId, userId: targetUserId });
|
|
||||||
}
|
|
||||||
// Convex reactive queries auto-update members list
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to assign/unassign role:', e);
|
console.error('Failed to assign/unassign role:', e);
|
||||||
}
|
}
|
||||||
@@ -92,17 +88,21 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const canManageRoles = myPermissions.manage_roles;
|
||||||
|
const disabledOpacity = canManageRoles ? 1 : 0.5;
|
||||||
|
const labelStyle = { display: 'block', color: '#b9bbbe', fontSize: '12px', fontWeight: '700', marginBottom: 8 };
|
||||||
|
const editableRoles = roles.filter(r => r.name !== 'Owner');
|
||||||
|
|
||||||
const renderRolesTab = () => (
|
const renderRolesTab = () => (
|
||||||
<div style={{ display: 'flex', height: '100%' }}>
|
<div style={{ display: 'flex', height: '100%' }}>
|
||||||
{/* Role List */}
|
|
||||||
<div style={{ width: '200px', borderRight: '1px solid #3f4147', marginRight: '20px' }}>
|
<div style={{ width: '200px', borderRight: '1px solid #3f4147', marginRight: '20px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '10px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '10px' }}>
|
||||||
<h3 style={{ color: '#b9bbbe', fontSize: '12px' }}>ROLES</h3>
|
<h3 style={{ color: '#b9bbbe', fontSize: '12px' }}>ROLES</h3>
|
||||||
{myPermissions.manage_roles && (
|
{canManageRoles && (
|
||||||
<button onClick={handleCreateRole} style={{ background: 'transparent', border: 'none', color: '#b9bbbe', cursor: 'pointer' }}>+</button>
|
<button onClick={handleCreateRole} style={{ background: 'transparent', border: 'none', color: '#b9bbbe', cursor: 'pointer' }}>+</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{roles.filter(r => r.name !== 'Owner').map(r => (
|
{editableRoles.map(r => (
|
||||||
<div
|
<div
|
||||||
key={r._id}
|
key={r._id}
|
||||||
onClick={() => setSelectedRole(r)}
|
onClick={() => setSelectedRole(r)}
|
||||||
@@ -119,29 +119,28 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Edit Panel */}
|
|
||||||
{selectedRole ? (
|
{selectedRole ? (
|
||||||
<div className="no-scrollbar" style={{ flex: 1, overflowY: 'auto' }}>
|
<div className="no-scrollbar" style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
<h2 style={{ color: 'white', marginTop: 0 }}>Edit Role - {selectedRole.name}</h2>
|
<h2 style={{ color: 'white', marginTop: 0 }}>Edit Role - {selectedRole.name}</h2>
|
||||||
|
|
||||||
<label style={{ display: 'block', color: '#b9bbbe', fontSize: '12px', fontWeight: '700', marginBottom: 8 }}>ROLE NAME</label>
|
<label style={labelStyle}>ROLE NAME</label>
|
||||||
<input
|
<input
|
||||||
value={selectedRole.name}
|
value={selectedRole.name}
|
||||||
onChange={(e) => handleUpdateRole(selectedRole._id, { name: e.target.value })}
|
onChange={(e) => handleUpdateRole(selectedRole._id, { name: e.target.value })}
|
||||||
disabled={!myPermissions.manage_roles}
|
disabled={!canManageRoles}
|
||||||
style={{ width: '100%', padding: 10, background: '#202225', border: 'none', borderRadius: 4, color: 'white', marginBottom: 20, opacity: !myPermissions.manage_roles ? 0.5 : 1 }}
|
style={{ width: '100%', padding: 10, background: '#202225', border: 'none', borderRadius: 4, color: 'white', marginBottom: 20, opacity: disabledOpacity }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label style={{ display: 'block', color: '#b9bbbe', fontSize: '12px', fontWeight: '700', marginBottom: 8 }}>ROLE COLOR</label>
|
<label style={labelStyle}>ROLE COLOR</label>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={selectedRole.color}
|
value={selectedRole.color}
|
||||||
onChange={(e) => handleUpdateRole(selectedRole._id, { color: e.target.value })}
|
onChange={(e) => handleUpdateRole(selectedRole._id, { color: e.target.value })}
|
||||||
disabled={!myPermissions.manage_roles}
|
disabled={!canManageRoles}
|
||||||
style={{ width: '100%', height: 40, border: 'none', padding: 0, marginBottom: 20, opacity: !myPermissions.manage_roles ? 0.5 : 1 }}
|
style={{ width: '100%', height: 40, border: 'none', padding: 0, marginBottom: 20, opacity: disabledOpacity }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label style={{ display: 'block', color: '#b9bbbe', fontSize: '12px', fontWeight: '700', marginBottom: 8 }}>PERMISSIONS</label>
|
<label style={labelStyle}>PERMISSIONS</label>
|
||||||
{['manage_channels', 'manage_roles', 'create_invite', 'embed_links', 'attach_files'].map(perm => (
|
{['manage_channels', 'manage_roles', 'create_invite', 'embed_links', 'attach_files'].map(perm => (
|
||||||
<div key={perm} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10, paddingBottom: 10, borderBottom: '1px solid #3f4147' }}>
|
<div key={perm} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10, paddingBottom: 10, borderBottom: '1px solid #3f4147' }}>
|
||||||
<span style={{ color: 'white', textTransform: 'capitalize' }}>{perm.replace('_', ' ')}</span>
|
<span style={{ color: 'white', textTransform: 'capitalize' }}>{perm.replace('_', ' ')}</span>
|
||||||
@@ -149,17 +148,17 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedRole.permissions?.[perm] || false}
|
checked={selectedRole.permissions?.[perm] || false}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newPerms = { ...selectedRole.permissions, [perm]: e.target.checked };
|
handleUpdateRole(selectedRole._id, {
|
||||||
handleUpdateRole(selectedRole._id, { permissions: newPerms });
|
permissions: { ...selectedRole.permissions, [perm]: e.target.checked }
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
disabled={!myPermissions.manage_roles}
|
disabled={!canManageRoles}
|
||||||
style={{ transform: 'scale(1.5)', opacity: !myPermissions.manage_roles ? 0.5 : 1 }}
|
style={{ transform: 'scale(1.5)', opacity: disabledOpacity }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Prevent deleting Default Roles */}
|
{canManageRoles && selectedRole.name !== 'Owner' && selectedRole.name !== '@everyone' && (
|
||||||
{myPermissions.manage_roles && selectedRole.name !== 'Owner' && selectedRole.name !== '@everyone' && (
|
|
||||||
<button onClick={() => handleDeleteRole(selectedRole._id)} style={{ color: '#ed4245', background: 'transparent', border: '1px solid #ed4245', padding: '6px 12px', borderRadius: 4, marginTop: 20, cursor: 'pointer' }}>
|
<button onClick={() => handleDeleteRole(selectedRole._id)} style={{ color: '#ed4245', background: 'transparent', border: '1px solid #ed4245', padding: '6px 12px', borderRadius: 4, marginTop: 20, cursor: 'pointer' }}>
|
||||||
Delete Role
|
Delete Role
|
||||||
</button>
|
</button>
|
||||||
@@ -182,18 +181,18 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ color: 'white', fontWeight: 'bold' }}>{m.username}</div>
|
<div style={{ color: 'white', fontWeight: 'bold' }}>{m.username}</div>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
{m.roles && m.roles.map(r => (
|
{m.roles?.map(r => (
|
||||||
<span key={r._id} style={{ fontSize: 10, background: r.color, color: 'white', padding: '2px 4px', borderRadius: 4 }}>
|
<span key={r._id} style={{ fontSize: 10, background: r.color, color: 'white', padding: '2px 4px', borderRadius: 4 }}>
|
||||||
{r.name}
|
{r.name}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{canManageRoles && (
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
{roles.filter(r => r.name !== 'Owner').map(r => {
|
{editableRoles.map(r => {
|
||||||
const hasRole = m.roles?.some(ur => ur._id === r._id);
|
const hasRole = m.roles?.some(ur => ur._id === r._id);
|
||||||
return (
|
return (
|
||||||
myPermissions.manage_roles && (
|
|
||||||
<button
|
<button
|
||||||
key={r._id}
|
key={r._id}
|
||||||
onClick={() => handleAssignRole(r._id, m.id, !hasRole)}
|
onClick={() => handleAssignRole(r._id, m.id, !hasRole)}
|
||||||
@@ -205,15 +204,23 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
}}
|
}}
|
||||||
title={hasRole ? `Remove ${r.name}` : `Add ${r.name}`}
|
title={hasRole ? `Remove ${r.name}` : `Add ${r.name}`}
|
||||||
/>
|
/>
|
||||||
)
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderTabContent = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'Roles': return renderRolesTab();
|
||||||
|
case 'Members': return renderMembersTab();
|
||||||
|
default: return <div style={{ color: '#b9bbbe' }}>Server Name: Secure Chat<br/>Region: US-East</div>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'hsl(240 calc(1*7.143%) 10.98% /1)', zIndex: 1000, display: 'flex', color: '#dcddde' }}>
|
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'hsl(240 calc(1*7.143%) 10.98% /1)', zIndex: 1000, display: 'flex', color: '#dcddde' }}>
|
||||||
{renderSidebar()}
|
{renderSidebar()}
|
||||||
@@ -222,9 +229,7 @@ const ServerSettingsModal = ({ onClose }) => {
|
|||||||
<h2 style={{ color: 'white', margin: 0 }}>{activeTab}</h2>
|
<h2 style={{ color: 'white', margin: 0 }}>{activeTab}</h2>
|
||||||
<button onClick={onClose} style={{ background: 'transparent', border: '1px solid #b9bbbe', borderRadius: '50%', width: 36, height: 36, color: '#b9bbbe', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>✕</button>
|
<button onClick={onClose} style={{ background: 'transparent', border: '1px solid #b9bbbe', borderRadius: '50%', width: 36, height: 36, color: '#b9bbbe', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
{activeTab === 'Roles' && renderRolesTab()}
|
{renderTabContent()}
|
||||||
{activeTab === 'Members' && renderMembersTab()}
|
|
||||||
{activeTab === 'Overview' && <div style={{ color: '#b9bbbe' }}>Server Name: Secure Chat<br/>Region: US-East</div>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,40 @@ import disconnectIcon from '../assets/icons/disconnect.svg';
|
|||||||
import cameraIcon from '../assets/icons/camera.svg';
|
import cameraIcon from '../assets/icons/camera.svg';
|
||||||
import screenIcon from '../assets/icons/screen.svg';
|
import screenIcon from '../assets/icons/screen.svg';
|
||||||
|
|
||||||
// Helper Component for coloring SVGs
|
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||||
|
|
||||||
|
const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)';
|
||||||
|
const ICON_COLOR_ACTIVE = 'hsl(357.692 67.826% 54.902% / 1)';
|
||||||
|
|
||||||
|
const controlButtonStyle = {
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
};
|
||||||
|
|
||||||
|
function getUserColor(name) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToHex(bytes) {
|
||||||
|
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomHex(length) {
|
||||||
|
const bytes = new Uint8Array(length);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return bytesToHex(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
const ColoredIcon = ({ src, color, size = '20px' }) => (
|
const ColoredIcon = ({ src, color, size = '20px' }) => (
|
||||||
<div style={{
|
<div style={{
|
||||||
width: size,
|
width: size,
|
||||||
@@ -46,18 +79,6 @@ const UserControlPanel = ({ username }) => {
|
|||||||
|
|
||||||
const effectiveMute = isMuted || isDeafened;
|
const effectiveMute = isMuted || isDeafened;
|
||||||
|
|
||||||
const getUserColor = (name) => {
|
|
||||||
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < name.length; i++) {
|
|
||||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
|
||||||
}
|
|
||||||
return colors[Math.abs(hash) % colors.length];
|
|
||||||
};
|
|
||||||
|
|
||||||
const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)';
|
|
||||||
const ICON_COLOR_ACTIVE = 'hsl(357.692 67.826% 54.902% / 1)';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
height: '64px',
|
height: '64px',
|
||||||
@@ -94,7 +115,6 @@ const UserControlPanel = ({ username }) => {
|
|||||||
}}>
|
}}>
|
||||||
{(username || '?').substring(0, 1).toUpperCase()}
|
{(username || '?').substring(0, 1).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
{/* Status Indicator */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: '-2px',
|
bottom: '-2px',
|
||||||
@@ -118,57 +138,19 @@ const UserControlPanel = ({ username }) => {
|
|||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div style={{ display: 'flex' }}>
|
<div style={{ display: 'flex' }}>
|
||||||
<button
|
<button onClick={toggleMute} title={effectiveMute ? "Unmute" : "Mute"} style={controlButtonStyle}>
|
||||||
onClick={toggleMute}
|
|
||||||
title={effectiveMute ? "Unmute" : "Mute"}
|
|
||||||
style={{
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '6px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ColoredIcon
|
<ColoredIcon
|
||||||
src={effectiveMute ? mutedIcon : muteIcon}
|
src={effectiveMute ? mutedIcon : muteIcon}
|
||||||
color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
|
color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={toggleDeafen} title={isDeafened ? "Undeafen" : "Deafen"} style={controlButtonStyle}>
|
||||||
onClick={toggleDeafen}
|
|
||||||
title={isDeafened ? "Undeafen" : "Deafen"}
|
|
||||||
style={{
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '6px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ColoredIcon
|
<ColoredIcon
|
||||||
src={isDeafened ? defeanedIcon : defeanIcon}
|
src={isDeafened ? defeanedIcon : defeanIcon}
|
||||||
color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
|
color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button title="User Settings" style={controlButtonStyle}>
|
||||||
title="User Settings"
|
|
||||||
style={{
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '6px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ColoredIcon
|
<ColoredIcon
|
||||||
src={settingsIcon}
|
src={settingsIcon}
|
||||||
color={ICON_COLOR_DEFAULT}
|
color={ICON_COLOR_DEFAULT}
|
||||||
@@ -181,6 +163,92 @@ const UserControlPanel = ({ username }) => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const headerButtonStyle = {
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: '#b9bbbe',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '18px',
|
||||||
|
padding: '0 4px'
|
||||||
|
};
|
||||||
|
|
||||||
|
const voicePanelButtonStyle = {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: '32px',
|
||||||
|
background: 'hsl(240 calc(1*4%) 60.784% /0.0784313725490196)',
|
||||||
|
border: 'hsl(0 calc(1*0%) 100% /0.0784313725490196)',
|
||||||
|
borderColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.0392156862745098) 100%,hsl(0 0% 0% /0.0392156862745098) 0%)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center'
|
||||||
|
};
|
||||||
|
|
||||||
|
const liveBadgeStyle = {
|
||||||
|
backgroundColor: '#ed4245',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '0 6px',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textAlign: 'center',
|
||||||
|
height: '16px',
|
||||||
|
minHeight: '16px',
|
||||||
|
minWidth: '16px',
|
||||||
|
color: 'hsl(0 calc(1*0%) 100% /1)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: '.02em',
|
||||||
|
lineHeight: '1.3333333333333333',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: '4px'
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIVE_SPEAKER_SHADOW = '0 0 0 0px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 2px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 3px color-mix(in oklab, hsl(240 calc(1*7.143%) 10.98% /1) 100%, #000 0%)';
|
||||||
|
const VOICE_ACTIVE_COLOR = "color-mix(in oklab, hsl(132.809 calc(1*34.902%) 50% /1) 100%, #000 0%)";
|
||||||
|
|
||||||
|
async function encryptKeyForUsers(convex, channelId, keyHex) {
|
||||||
|
const users = await convex.query(api.auth.getPublicKeys, {});
|
||||||
|
const batchKeys = [];
|
||||||
|
|
||||||
|
for (const u of users) {
|
||||||
|
if (!u.public_identity_key) continue;
|
||||||
|
try {
|
||||||
|
const payload = JSON.stringify({ [channelId]: keyHex });
|
||||||
|
const encryptedKeyHex = await window.cryptoAPI.publicEncrypt(u.public_identity_key, payload);
|
||||||
|
batchKeys.push({
|
||||||
|
channelId,
|
||||||
|
userId: u.id,
|
||||||
|
encryptedKeyBundle: encryptedKeyHex,
|
||||||
|
keyVersion: 1
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to encrypt for user", u.id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScreenCaptureConstraints(selection) {
|
||||||
|
if (selection.type === 'device') {
|
||||||
|
return { video: { deviceId: { exact: selection.deviceId } }, audio: false };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
audio: false,
|
||||||
|
video: {
|
||||||
|
mandatory: {
|
||||||
|
chromeMediaSource: 'desktop',
|
||||||
|
chromeMediaSourceId: selection.sourceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels }) => {
|
const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels }) => {
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
|
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
|
||||||
@@ -191,14 +259,10 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
|
|
||||||
const convex = useConvex();
|
const convex = useConvex();
|
||||||
|
|
||||||
// Callbacks for Modal - Convex is reactive, no need to manually refresh
|
const onRenameChannel = () => {};
|
||||||
const onRenameChannel = (id, newName) => {
|
|
||||||
// Convex reactive queries auto-update
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDeleteChannel = (id) => {
|
const onDeleteChannel = (id) => {
|
||||||
if (activeChannel === id) onSelectChannel(null);
|
if (activeChannel === id) onSelectChannel(null);
|
||||||
// Convex reactive queries auto-update
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing } = useVoice();
|
const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing } = useVoice();
|
||||||
@@ -211,13 +275,13 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
|
|
||||||
const handleSubmitCreate = async (e) => {
|
const handleSubmitCreate = async (e) => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
|
|
||||||
if (!newChannelName.trim()) {
|
if (!newChannelName.trim()) {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = newChannelName.trim();
|
const name = newChannelName.trim();
|
||||||
const type = newChannelType;
|
|
||||||
const userId = localStorage.getItem('userId');
|
const userId = localStorage.getItem('userId');
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -227,54 +291,19 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Create Channel via Convex
|
const { id: channelId } = await convex.mutation(api.channels.create, { name, type: newChannelType });
|
||||||
const { id: channelId } = await convex.mutation(api.channels.create, { name, type });
|
const keyHex = randomHex(32);
|
||||||
|
|
||||||
// 2. Generate Key
|
|
||||||
const keyBytes = new Uint8Array(32);
|
|
||||||
crypto.getRandomValues(keyBytes);
|
|
||||||
const keyHex = Array.from(keyBytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
|
|
||||||
// 3. Encrypt Key for ALL Users
|
|
||||||
try {
|
|
||||||
const users = await convex.query(api.auth.getPublicKeys, {});
|
|
||||||
|
|
||||||
const batchKeys = [];
|
|
||||||
|
|
||||||
for (const u of users) {
|
|
||||||
if (!u.public_identity_key) continue;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = JSON.stringify({ [channelId]: keyHex });
|
await encryptKeyForUsers(convex, channelId, keyHex);
|
||||||
const encryptedKeyHex = await window.cryptoAPI.publicEncrypt(u.public_identity_key, payload);
|
|
||||||
|
|
||||||
batchKeys.push({
|
|
||||||
channelId,
|
|
||||||
userId: u.id,
|
|
||||||
encryptedKeyBundle: encryptedKeyHex,
|
|
||||||
keyVersion: 1
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to encrypt for user", u.id, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Upload Keys Batch via Convex
|
|
||||||
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
|
|
||||||
|
|
||||||
// No need to notify - Convex queries are reactive!
|
|
||||||
|
|
||||||
} catch (keyErr) {
|
} catch (keyErr) {
|
||||||
console.error("Critical: Failed to distribute keys", keyErr);
|
console.error("Critical: Failed to distribute keys", keyErr);
|
||||||
alert("Channel created but key distribution failed.");
|
alert("Channel created but key distribution failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Done - Convex reactive queries auto-update the channel list
|
|
||||||
setIsCreating(false);
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
alert("Failed to create channel: " + err.message);
|
alert("Failed to create channel: " + err.message);
|
||||||
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -286,14 +315,6 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Generate Invite Code & Secret
|
|
||||||
const inviteCode = crypto.randomUUID();
|
|
||||||
const inviteSecretBytes = new Uint8Array(32);
|
|
||||||
crypto.getRandomValues(inviteSecretBytes);
|
|
||||||
const inviteSecret = Array.from(inviteSecretBytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
|
|
||||||
// 2. Prepare Key Bundle
|
|
||||||
const generalChannel = channels.find(c => c.name === 'general');
|
const generalChannel = channels.find(c => c.name === 'general');
|
||||||
const targetChannelId = generalChannel ? generalChannel._id : activeChannel;
|
const targetChannelId = generalChannel ? generalChannel._id : activeChannel;
|
||||||
|
|
||||||
@@ -302,27 +323,21 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetKey = channelKeys ? channelKeys[targetChannelId] : null;
|
const targetKey = channelKeys?.[targetChannelId];
|
||||||
|
|
||||||
if (!targetKey) {
|
if (!targetKey) {
|
||||||
alert("Error: You don't have the key for this channel yet, so you can't invite others.");
|
alert("Error: You don't have the key for this channel yet, so you can't invite others.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = JSON.stringify({
|
try {
|
||||||
[targetChannelId]: targetKey
|
const inviteCode = crypto.randomUUID();
|
||||||
});
|
const inviteSecret = randomHex(32);
|
||||||
|
|
||||||
// 3. Encrypt Payload
|
const payload = JSON.stringify({ [targetChannelId]: targetKey });
|
||||||
const encrypted = await window.cryptoAPI.encryptData(payload, inviteSecret);
|
const encrypted = await window.cryptoAPI.encryptData(payload, inviteSecret);
|
||||||
|
const blob = JSON.stringify({ c: encrypted.content, t: encrypted.tag, iv: encrypted.iv });
|
||||||
|
|
||||||
const blob = JSON.stringify({
|
|
||||||
c: encrypted.content,
|
|
||||||
t: encrypted.tag,
|
|
||||||
iv: encrypted.iv
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Create invite via Convex
|
|
||||||
await convex.mutation(api.invites.create, {
|
await convex.mutation(api.invites.create, {
|
||||||
code: inviteCode,
|
code: inviteCode,
|
||||||
encryptedPayload: blob,
|
encryptedPayload: blob,
|
||||||
@@ -330,50 +345,27 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
keyVersion: 1
|
keyVersion: 1
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Show Link
|
|
||||||
const link = `http://localhost:5173/#/register?code=${inviteCode}&key=${inviteSecret}`;
|
const link = `http://localhost:5173/#/register?code=${inviteCode}&key=${inviteSecret}`;
|
||||||
navigator.clipboard.writeText(link);
|
navigator.clipboard.writeText(link);
|
||||||
alert(`Invite Link Copied to Clipboard!\n\n${link}`);
|
alert(`Invite Link Copied to Clipboard!\n\n${link}`);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Invite Error:", e);
|
console.error("Invite Error:", e);
|
||||||
alert("Failed to create invite. See console.");
|
alert("Failed to create invite. See console.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Screen Share Handler
|
|
||||||
const handleScreenShareSelect = async (selection) => {
|
const handleScreenShareSelect = async (selection) => {
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Unpublish existing screen share if any
|
|
||||||
if (room.localParticipant.isScreenShareEnabled) {
|
if (room.localParticipant.isScreenShareEnabled) {
|
||||||
await room.localParticipant.setScreenShareEnabled(false);
|
await room.localParticipant.setScreenShareEnabled(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture based on selection
|
const stream = await navigator.mediaDevices.getUserMedia(getScreenCaptureConstraints(selection));
|
||||||
let stream;
|
|
||||||
if (selection.type === 'device') {
|
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
video: { deviceId: { exact: selection.deviceId } },
|
|
||||||
audio: false
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Electron Screen/Window
|
|
||||||
stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: false,
|
|
||||||
video: {
|
|
||||||
mandatory: {
|
|
||||||
chromeMediaSource: 'desktop',
|
|
||||||
chromeMediaSourceId: selection.sourceId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish the video track
|
|
||||||
const track = stream.getVideoTracks()[0];
|
const track = stream.getVideoTracks()[0];
|
||||||
if (track) {
|
if (!track) return;
|
||||||
|
|
||||||
await room.localParticipant.publishTrack(track, {
|
await room.localParticipant.publishTrack(track, {
|
||||||
name: 'screen_share',
|
name: 'screen_share',
|
||||||
source: Track.Source.ScreenShare
|
source: Track.Source.ScreenShare
|
||||||
@@ -385,15 +377,12 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
setScreenSharing(false);
|
setScreenSharing(false);
|
||||||
room.localParticipant.setScreenShareEnabled(false).catch(console.error);
|
room.localParticipant.setScreenShareEnabled(false).catch(console.error);
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error sharing screen:", err);
|
console.error("Error sharing screen:", err);
|
||||||
alert("Failed to share screen: " + err.message);
|
alert("Failed to share screen: " + err.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle Modal instead of direct toggle
|
|
||||||
const handleScreenShareClick = () => {
|
const handleScreenShareClick = () => {
|
||||||
if (room?.localParticipant.isScreenShareEnabled) {
|
if (room?.localParticipant.isScreenShareEnabled) {
|
||||||
room.localParticipant.setScreenShareEnabled(false);
|
room.localParticipant.setScreenShareEnabled(false);
|
||||||
@@ -403,52 +392,59 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleChannelClick = (channel) => {
|
||||||
|
if (channel.type === 'voice' && voiceChannelId !== channel._id) {
|
||||||
|
connectToVoice(channel._id, channel.name, localStorage.getItem('userId'));
|
||||||
|
} else {
|
||||||
|
onSelectChannel(channel._id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const renderDMView = () => (
|
||||||
<div className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
|
||||||
<div className="server-list">
|
|
||||||
{/* Home Button */}
|
|
||||||
<div
|
|
||||||
className={`server-icon ${view === 'me' ? 'active' : ''}`}
|
|
||||||
onClick={() => onViewChange('me')}
|
|
||||||
style={{
|
|
||||||
backgroundColor: view === 'me' ? '#5865F2' : '#36393f',
|
|
||||||
color: view === 'me' ? '#fff' : '#dcddde',
|
|
||||||
marginBottom: '8px',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="28" height="20" viewBox="0 0 28 20">
|
|
||||||
<path fill="currentColor" d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.0172 1.11817 13.0907 1.11817 11.1372 1.4184C10.9331 0.934541 10.7018 0.461742 10.4496 0C8.59007 0.318797 6.78726 0.879656 5.0768 1.67671C1.65217 6.84883 0.706797 11.8997 1.166 16.858C3.39578 18.5135 5.56066 19.5165 7.6473 20.1417C8.16912 19.4246 8.63212 18.6713 9.02347 17.8863C8.25707 17.5929 7.52187 17.2415 6.82914 16.8374C7.0147 16.6975 7.19515 16.5518 7.36941 16.402C11.66 18.396 16.4523 18.396 20.7186 16.402C20.8953 16.5518 21.0758 16.6975 21.2613 16.8374C20.5663 17.2435 19.8288 17.5947 19.0624 17.8863C19.4537 18.6713 19.9167 19.4246 20.4385 20.1417C22.5276 19.5165 24.6925 18.5135 26.9223 16.858C27.4684 11.2365 25.9961 6.22055 23.0212 1.67671ZM9.68041 13.6383C8.39754 13.6383 7.34085 12.4456 7.34085 10.994C7.34085 9.5424 8.37555 8.34973 9.68041 8.34973C10.9893 8.34973 12.0395 9.5424 12.0175 10.994C12.0175 12.4456 10.9893 13.6383 9.68041 13.6383ZM18.3161 13.6383C17.0332 13.6383 15.9765 12.4456 15.9765 10.994C15.9765 9.5424 17.0112 8.34973 18.3161 8.34973C19.625 8.34973 20.6752 9.5424 20.6532 10.994C20.6532 12.4456 19.625 13.6383 18.3161 13.6383Z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* The Server Icon (Secure Chat) */}
|
|
||||||
<div
|
|
||||||
className={`server-icon ${view === 'server' ? 'active' : ''}`}
|
|
||||||
onClick={() => onViewChange('server')}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
>Sc</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Channel List Area */}
|
|
||||||
{view === 'me' ? (
|
|
||||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||||
<DMList
|
<DMList
|
||||||
dmChannels={dmChannels}
|
dmChannels={dmChannels}
|
||||||
activeDMChannel={activeDMChannel}
|
activeDMChannel={activeDMChannel}
|
||||||
onSelectDM={(dm) => {
|
onSelectDM={(dm) => setActiveDMChannel(dm === 'friends' ? null : dm)}
|
||||||
if (dm === 'friends') {
|
|
||||||
setActiveDMChannel(null);
|
|
||||||
} else {
|
|
||||||
setActiveDMChannel(dm);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onOpenDM={onOpenDM}
|
onOpenDM={onOpenDM}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
);
|
||||||
|
|
||||||
|
const renderVoiceUsers = (channel) => {
|
||||||
|
const users = voiceStates[channel._id];
|
||||||
|
if (channel.type !== 'voice' || !users?.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginLeft: 32, marginBottom: 8 }}>
|
||||||
|
{users.map(user => (
|
||||||
|
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 24, height: 24, borderRadius: '50%',
|
||||||
|
backgroundColor: '#5865F2',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
marginRight: 8, fontSize: 10, color: 'white',
|
||||||
|
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
|
||||||
|
}}>
|
||||||
|
{user.username.substring(0, 1).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span style={{ color: '#b9bbbe', fontSize: 14 }}>{user.username}</span>
|
||||||
|
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center' }}>
|
||||||
|
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
|
||||||
|
{(user.isMuted || user.isDeafened) && (
|
||||||
|
<ColoredIcon src={mutedIcon} color="#b9bbbe" size="14px" />
|
||||||
|
)}
|
||||||
|
{user.isDeafened && (
|
||||||
|
<ColoredIcon src={defeanedIcon} color="#b9bbbe" size="14px" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderServerView = () => (
|
||||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
|
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
<div className="channel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div className="channel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<span
|
<span
|
||||||
@@ -459,39 +455,15 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
Secure Chat ▾
|
Secure Chat ▾
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
<button
|
<button onClick={handleStartCreate} title="Create New Channel" style={{ ...headerButtonStyle, marginRight: '4px' }}>
|
||||||
onClick={handleStartCreate}
|
|
||||||
title="Create New Channel"
|
|
||||||
style={{
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
color: '#b9bbbe',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '18px',
|
|
||||||
padding: '0 4px',
|
|
||||||
marginRight: '4px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={handleCreateInvite} title="Create Invite Link" style={headerButtonStyle}>
|
||||||
onClick={handleCreateInvite}
|
|
||||||
title="Create Invite Link"
|
|
||||||
style={{
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
color: '#b9bbbe',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '18px',
|
|
||||||
padding: '0 4px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🔗
|
🔗
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inline Create Channel Input */}
|
|
||||||
{isCreating && (
|
{isCreating && (
|
||||||
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
|
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
|
||||||
<form onSubmit={handleSubmitCreate}>
|
<form onSubmit={handleSubmitCreate}>
|
||||||
@@ -531,17 +503,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
<React.Fragment key={channel._id}>
|
<React.Fragment key={channel._id}>
|
||||||
<div
|
<div
|
||||||
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
|
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => handleChannelClick(channel)}
|
||||||
if (channel.type === 'voice') {
|
|
||||||
if (voiceChannelId === channel._id) {
|
|
||||||
onSelectChannel(channel._id);
|
|
||||||
} else {
|
|
||||||
connectToVoice(channel._id, channel.name, localStorage.getItem('userId'));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onSelectChannel(channel._id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -556,10 +518,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
<ColoredIcon
|
<ColoredIcon
|
||||||
src={voiceIcon}
|
src={voiceIcon}
|
||||||
size="16px"
|
size="16px"
|
||||||
color={voiceStates[channel._id]?.length > 0
|
color={voiceStates[channel._id]?.length > 0 ? VOICE_ACTIVE_COLOR : "#8e9297"}
|
||||||
? "color-mix(in oklab, hsl(132.809 calc(1*34.902%) 50% /1) 100%, #000 0%)"
|
|
||||||
: "#8e9297"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -589,65 +548,41 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
⚙️
|
⚙️
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{channel.type === 'voice' && voiceStates[channel._id] && voiceStates[channel._id].length > 0 && (
|
{renderVoiceUsers(channel)}
|
||||||
<div style={{ marginLeft: 32, marginBottom: 8 }}>
|
|
||||||
{voiceStates[channel._id].map(user => (
|
|
||||||
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
|
|
||||||
<div style={{
|
|
||||||
width: 24, height: 24, borderRadius: '50%',
|
|
||||||
backgroundColor: '#5865F2',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
marginRight: 8, fontSize: 10, color: 'white',
|
|
||||||
boxShadow: activeSpeakers.has(user.userId)
|
|
||||||
? '0 0 0 0px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 2px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 3px color-mix(in oklab, hsl(240 calc(1*7.143%) 10.98% /1) 100%, #000 0%)'
|
|
||||||
: 'none'
|
|
||||||
}}>
|
|
||||||
{user.username.substring(0, 1).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span style={{ color: '#b9bbbe', fontSize: 14 }}>{user.username}</span>
|
|
||||||
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center' }}>
|
|
||||||
{user.isScreenSharing && (
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: '#ed4245',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '0 6px',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textAlign: 'center',
|
|
||||||
height: '16px',
|
|
||||||
minHeight: '16px',
|
|
||||||
minWidth: '16px',
|
|
||||||
color: 'hsl(0 calc(1*0%) 100% /1)',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: '700',
|
|
||||||
letterSpacing: '.02em',
|
|
||||||
lineHeight: '1.3333333333333333',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginRight: '4px'
|
|
||||||
}}>
|
|
||||||
Live
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(user.isMuted || user.isDeafened) && (
|
|
||||||
<ColoredIcon src={mutedIcon} color="#b9bbbe" size="14px" />
|
|
||||||
)}
|
|
||||||
{user.isDeafened && (
|
|
||||||
<ColoredIcon src={defeanedIcon} color="#b9bbbe" size="14px" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||||
|
<div className="server-list">
|
||||||
|
<div
|
||||||
|
className={`server-icon ${view === 'me' ? 'active' : ''}`}
|
||||||
|
onClick={() => onViewChange('me')}
|
||||||
|
style={{
|
||||||
|
backgroundColor: view === 'me' ? '#5865F2' : '#36393f',
|
||||||
|
color: view === 'me' ? '#fff' : '#dcddde',
|
||||||
|
marginBottom: '8px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="28" height="20" viewBox="0 0 28 20">
|
||||||
|
<path fill="currentColor" d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.0172 1.11817 13.0907 1.11817 11.1372 1.4184C10.9331 0.934541 10.7018 0.461742 10.4496 0C8.59007 0.318797 6.78726 0.879656 5.0768 1.67671C1.65217 6.84883 0.706797 11.8997 1.166 16.858C3.39578 18.5135 5.56066 19.5165 7.6473 20.1417C8.16912 19.4246 8.63212 18.6713 9.02347 17.8863C8.25707 17.5929 7.52187 17.2415 6.82914 16.8374C7.0147 16.6975 7.19515 16.5518 7.36941 16.402C11.66 18.396 16.4523 18.396 20.7186 16.402C20.8953 16.5518 21.0758 16.6975 21.2613 16.8374C20.5663 17.2435 19.8288 17.5947 19.0624 17.8863C19.4537 18.6713 19.9167 19.4246 20.4385 20.1417C22.5276 19.5165 24.6925 18.5135 26.9223 16.858C27.4684 11.2365 25.9961 6.22055 23.0212 1.67671ZM9.68041 13.6383C8.39754 13.6383 7.34085 12.4456 7.34085 10.994C7.34085 9.5424 8.37555 8.34973 9.68041 8.34973C10.9893 8.34973 12.0395 9.5424 12.0175 10.994C12.0175 12.4456 10.9893 13.6383 9.68041 13.6383ZM18.3161 13.6383C17.0332 13.6383 15.9765 12.4456 15.9765 10.994C15.9765 9.5424 17.0112 8.34973 18.3161 8.34973C19.625 8.34973 20.6752 9.5424 20.6532 10.994C20.6532 12.4456 19.625 13.6383 18.3161 13.6383Z"/>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{/* Voice Connection Panel */}
|
|
||||||
|
<div
|
||||||
|
className={`server-icon ${view === 'server' ? 'active' : ''}`}
|
||||||
|
onClick={() => onViewChange('server')}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>Sc</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === 'me' ? renderDMView() : renderServerView()}
|
||||||
|
</div>
|
||||||
|
|
||||||
{connectionState === 'connected' && (
|
{connectionState === 'connected' && (
|
||||||
<div style={{
|
<div style={{
|
||||||
backgroundColor: '#292b2f',
|
backgroundColor: '#292b2f',
|
||||||
@@ -672,32 +607,18 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#dcddde', fontSize: 12, marginBottom: 8 }}>{voiceChannelName} / Secure Chat</div>
|
<div style={{ color: '#dcddde', fontSize: 12, marginBottom: 8 }}>{voiceChannelName} / Secure Chat</div>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
<button
|
<button onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)} title="Turn On Camera" style={voicePanelButtonStyle}>
|
||||||
onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)}
|
|
||||||
title="Turn On Camera"
|
|
||||||
style={{
|
|
||||||
flex: 1, alignItems: 'center', minHeight: '32px', padding: "calc(var(--space-8) - 1px) calc(var(--space-16) - 1px)", background: 'hsl(240 calc(1*4%) 60.784% /0.0784313725490196)', border: 'hsl(0 calc(1*0%) 100% /0.0784313725490196)', borderColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.0392156862745098) 100%,hsl(0 0% 0% /0.0392156862745098) 0%)', borderRadius: '8px', cursor: 'pointer', padding: '4px', display: 'flex', justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ColoredIcon src={cameraIcon} color="#b9bbbe" size="20px" />
|
<ColoredIcon src={cameraIcon} color="#b9bbbe" size="20px" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={handleScreenShareClick} title="Share Screen" style={voicePanelButtonStyle}>
|
||||||
onClick={handleScreenShareClick}
|
|
||||||
title="Share Screen"
|
|
||||||
style={{
|
|
||||||
flex: 1, alignItems: 'center', minHeight: '32px', padding: "calc(var(--space-8) - 1px) calc(var(--space-16) - 1px)", background: 'hsl(240 calc(1*4%) 60.784% /0.0784313725490196)', border: 'hsl(0 calc(1*0%) 100% /0.0784313725490196)', borderColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.0392156862745098) 100%,hsl(0 0% 0% /0.0392156862745098) 0%)', borderRadius: '8px', cursor: 'pointer', padding: '4px', display: 'flex', justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ColoredIcon src={screenIcon} color="#b9bbbe" size="20px" />
|
<ColoredIcon src={screenIcon} color="#b9bbbe" size="20px" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User Control Panel at Bottom, Spanning Full Width */}
|
|
||||||
<UserControlPanel username={username} />
|
<UserControlPanel username={username} />
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
{editingChannel && (
|
{editingChannel && (
|
||||||
<ChannelSettingsModal
|
<ChannelSettingsModal
|
||||||
channel={editingChannel}
|
channel={editingChannel}
|
||||||
|
|||||||
@@ -12,29 +12,7 @@ import unmuteSound from '../assets/sounds/unmute.mp3';
|
|||||||
import deafenSound from '../assets/sounds/deafen.mp3';
|
import deafenSound from '../assets/sounds/deafen.mp3';
|
||||||
import undeafenSound from '../assets/sounds/undeafen.mp3';
|
import undeafenSound from '../assets/sounds/undeafen.mp3';
|
||||||
|
|
||||||
const VoiceContext = createContext();
|
const soundMap = {
|
||||||
|
|
||||||
export const useVoice = () => useContext(VoiceContext);
|
|
||||||
|
|
||||||
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 [isMuted, setIsMuted] = useState(false);
|
|
||||||
const [isDeafened, setIsDeafened] = 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,
|
join: joinSound,
|
||||||
leave: leaveSound,
|
leave: leaveSound,
|
||||||
mute: muteSound,
|
mute: muteSound,
|
||||||
@@ -42,13 +20,43 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
deafen: deafenSound,
|
deafen: deafenSound,
|
||||||
undeafen: undeafenSound
|
undeafen: undeafenSound
|
||||||
};
|
};
|
||||||
const src = sounds[type];
|
|
||||||
if (src) {
|
const VoiceContext = createContext();
|
||||||
|
|
||||||
|
export const useVoice = () => useContext(VoiceContext);
|
||||||
|
|
||||||
|
function playSound(type) {
|
||||||
|
const src = soundMap[type];
|
||||||
|
if (!src) return;
|
||||||
const audio = new Audio(src);
|
const audio = new Audio(src);
|
||||||
audio.volume = 0.5;
|
audio.volume = 0.5;
|
||||||
audio.play().catch(e => console.error("Sound play failed", e));
|
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());
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [isDeafened, setIsDeafened] = useState(false);
|
||||||
|
const [isScreenSharing, setIsScreenSharingLocal] = useState(false);
|
||||||
|
|
||||||
|
const convex = useConvex();
|
||||||
|
|
||||||
|
const voiceStates = useQuery(api.voiceState.getAll) || {};
|
||||||
|
|
||||||
|
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) => {
|
const connectToVoice = async (channelId, channelName, userId) => {
|
||||||
if (activeChannelId === channelId) return;
|
if (activeChannelId === channelId) return;
|
||||||
@@ -60,7 +68,6 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
setConnectionState('connecting');
|
setConnectionState('connecting');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get LiveKit token via Convex action
|
|
||||||
const { token: lkToken } = await convex.action(api.voice.getToken, {
|
const { token: lkToken } = await convex.action(api.voice.getToken, {
|
||||||
channelId,
|
channelId,
|
||||||
userId,
|
userId,
|
||||||
@@ -71,30 +78,24 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
|
|
||||||
setToken(lkToken);
|
setToken(lkToken);
|
||||||
|
|
||||||
// Disable adaptiveStream to ensure all tracks are available/subscribed immediately
|
|
||||||
const newRoom = new Room({ adaptiveStream: false, dynacast: false, autoSubscribe: true });
|
const newRoom = new Room({ adaptiveStream: false, dynacast: false, autoSubscribe: true });
|
||||||
const liveKitUrl = import.meta.env.VITE_LIVEKIT_URL;
|
await newRoom.connect(import.meta.env.VITE_LIVEKIT_URL, lkToken);
|
||||||
await newRoom.connect(liveKitUrl, lkToken);
|
|
||||||
|
|
||||||
// Auto-enable microphone & Apply Mute/Deafen State
|
await newRoom.localParticipant.setMicrophoneEnabled(!isMuted && !isDeafened);
|
||||||
const shouldEnableMic = !isMuted && !isDeafened;
|
|
||||||
await newRoom.localParticipant.setMicrophoneEnabled(shouldEnableMic);
|
|
||||||
|
|
||||||
setRoom(newRoom);
|
setRoom(newRoom);
|
||||||
setConnectionState('connected');
|
setConnectionState('connected');
|
||||||
window.voiceRoom = newRoom; // For debugging
|
window.voiceRoom = newRoom;
|
||||||
playSound('join');
|
playSound('join');
|
||||||
|
|
||||||
// Update voice state in Convex
|
|
||||||
await convex.mutation(api.voiceState.join, {
|
await convex.mutation(api.voiceState.join, {
|
||||||
channelId,
|
channelId,
|
||||||
userId,
|
userId,
|
||||||
username: localStorage.getItem('username') || 'Unknown',
|
username: localStorage.getItem('username') || 'Unknown',
|
||||||
isMuted: isMuted,
|
isMuted,
|
||||||
isDeafened: isDeafened,
|
isDeafened,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Events
|
|
||||||
newRoom.on(RoomEvent.Disconnected, async (reason) => {
|
newRoom.on(RoomEvent.Disconnected, async (reason) => {
|
||||||
console.warn('Voice Room Disconnected. Reason:', reason);
|
console.warn('Voice Room Disconnected. Reason:', reason);
|
||||||
playSound('leave');
|
playSound('leave');
|
||||||
@@ -104,7 +105,6 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
setToken(null);
|
setToken(null);
|
||||||
setActiveSpeakers(new Set());
|
setActiveSpeakers(new Set());
|
||||||
|
|
||||||
// Remove voice state in Convex
|
|
||||||
try {
|
try {
|
||||||
await convex.mutation(api.voiceState.leave, { userId });
|
await convex.mutation(api.voiceState.leave, { userId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -113,9 +113,7 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
||||||
const newActive = new Set();
|
setActiveSpeakers(new Set(speakers.map(p => p.identity)));
|
||||||
speakers.forEach(p => newActive.add(p.identity));
|
|
||||||
setActiveSpeakers(newActive);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -137,63 +135,22 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
if (room) {
|
if (room) {
|
||||||
room.localParticipant.setMicrophoneEnabled(!nextState);
|
room.localParticipant.setMicrophoneEnabled(!nextState);
|
||||||
}
|
}
|
||||||
|
await updateVoiceState({ isMuted: nextState });
|
||||||
const userId = localStorage.getItem('userId');
|
|
||||||
if (userId && activeChannelId) {
|
|
||||||
try {
|
|
||||||
await convex.mutation(api.voiceState.updateState, {
|
|
||||||
userId,
|
|
||||||
isMuted: nextState,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to update mute state:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleDeafen = async () => {
|
const toggleDeafen = async () => {
|
||||||
const nextState = !isDeafened;
|
const nextState = !isDeafened;
|
||||||
setIsDeafened(nextState);
|
setIsDeafened(nextState);
|
||||||
playSound(nextState ? 'deafen' : 'undeafen');
|
playSound(nextState ? 'deafen' : 'undeafen');
|
||||||
if (nextState) {
|
|
||||||
if (room && !isMuted) {
|
if (room && !isMuted) {
|
||||||
room.localParticipant.setMicrophoneEnabled(false);
|
room.localParticipant.setMicrophoneEnabled(!nextState);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (room && !isMuted) {
|
|
||||||
room.localParticipant.setMicrophoneEnabled(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = localStorage.getItem('userId');
|
|
||||||
if (userId && activeChannelId) {
|
|
||||||
try {
|
|
||||||
await convex.mutation(api.voiceState.updateState, {
|
|
||||||
userId,
|
|
||||||
isDeafened: nextState,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to update deafen state:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
await updateVoiceState({ isDeafened: nextState });
|
||||||
};
|
};
|
||||||
|
|
||||||
const [isScreenSharing, setIsScreenSharingLocal] = useState(false);
|
|
||||||
|
|
||||||
const setScreenSharing = async (active) => {
|
const setScreenSharing = async (active) => {
|
||||||
setIsScreenSharingLocal(active);
|
setIsScreenSharingLocal(active);
|
||||||
|
await updateVoiceState({ isScreenSharing: active });
|
||||||
const userId = localStorage.getItem('userId');
|
|
||||||
if (userId && activeChannelId) {
|
|
||||||
try {
|
|
||||||
await convex.mutation(api.voiceState.updateState, {
|
|
||||||
userId,
|
|
||||||
isScreenSharing: active,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to update screen sharing state:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -220,7 +177,6 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
room={room}
|
room={room}
|
||||||
style={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden' }}
|
style={{ position: 'absolute', width: 0, height: 0, overflow: 'hidden' }}
|
||||||
>
|
>
|
||||||
{/* Mute audio renderer if deafened */}
|
|
||||||
<RoomAudioRenderer muted={isDeafened} />
|
<RoomAudioRenderer muted={isDeafened} />
|
||||||
</LiveKitRoom>
|
</LiveKitRoom>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -273,6 +273,28 @@ body {
|
|||||||
background-color: rgba(2, 2, 2, 0.06);
|
background-color: rgba(2, 2, 2, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-grouped {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-timestamp-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grouped-timestamp {
|
||||||
|
display: none;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #72767d;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-grouped:hover .grouped-timestamp {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.message-avatar-wrapper {
|
.message-avatar-wrapper {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
margin-right: 16px;
|
margin-right: 16px;
|
||||||
@@ -631,6 +653,54 @@ body {
|
|||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Loading spinner */
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-top-color: #5865f2;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Channel beginning indicator */
|
||||||
|
.channel-beginning {
|
||||||
|
padding: 16px 16px 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-beginning-icon {
|
||||||
|
width: 68px;
|
||||||
|
height: 68px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #41434a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-beginning-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-beginning-subtitle {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #949ba4;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Utility to hide scrollbar but allow scrolling */
|
/* Utility to hide scrollbar but allow scrolling */
|
||||||
.no-scrollbar {
|
.no-scrollbar {
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
|||||||
@@ -8,33 +8,27 @@ import { useVoice } from '../contexts/VoiceContext';
|
|||||||
import FriendsView from '../components/FriendsView';
|
import FriendsView from '../components/FriendsView';
|
||||||
|
|
||||||
const Chat = () => {
|
const Chat = () => {
|
||||||
const [view, setView] = useState('server'); // 'server' | 'me'
|
const [view, setView] = useState('server');
|
||||||
const [activeChannel, setActiveChannel] = useState(null);
|
const [activeChannel, setActiveChannel] = useState(null);
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [userId, setUserId] = useState(null);
|
const [userId, setUserId] = useState(null);
|
||||||
const [channelKeys, setChannelKeys] = useState({}); // { channelId: key_hex }
|
const [channelKeys, setChannelKeys] = useState({});
|
||||||
|
const [activeDMChannel, setActiveDMChannel] = useState(null);
|
||||||
// DM state
|
|
||||||
const [activeDMChannel, setActiveDMChannel] = useState(null); // { channel_id, other_username }
|
|
||||||
|
|
||||||
const convex = useConvex();
|
const convex = useConvex();
|
||||||
|
|
||||||
// Reactive channel list from Convex (auto-updates!)
|
|
||||||
const channels = useQuery(api.channels.list) || [];
|
const channels = useQuery(api.channels.list) || [];
|
||||||
|
|
||||||
// Reactive channel keys from Convex
|
|
||||||
const rawChannelKeys = useQuery(
|
const rawChannelKeys = useQuery(
|
||||||
api.channelKeys.getKeysForUser,
|
api.channelKeys.getKeysForUser,
|
||||||
userId ? { userId } : "skip"
|
userId ? { userId } : "skip"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reactive DM channels from Convex
|
|
||||||
const dmChannels = useQuery(
|
const dmChannels = useQuery(
|
||||||
api.dms.listDMs,
|
api.dms.listDMs,
|
||||||
userId ? { userId } : "skip"
|
userId ? { userId } : "skip"
|
||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
// Initialize user from localStorage
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedUsername = localStorage.getItem('username');
|
const storedUsername = localStorage.getItem('username');
|
||||||
const storedUserId = localStorage.getItem('userId');
|
const storedUserId = localStorage.getItem('userId');
|
||||||
@@ -42,58 +36,50 @@ const Chat = () => {
|
|||||||
if (storedUserId) setUserId(storedUserId);
|
if (storedUserId) setUserId(storedUserId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Decrypt channel keys when raw keys change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!rawChannelKeys || rawChannelKeys.length === 0) return;
|
if (!rawChannelKeys || rawChannelKeys.length === 0) return;
|
||||||
const privateKey = sessionStorage.getItem('privateKey');
|
const privateKey = sessionStorage.getItem('privateKey');
|
||||||
if (!privateKey) return;
|
if (!privateKey) return;
|
||||||
|
|
||||||
const decryptKeys = async () => {
|
async function decryptKeys() {
|
||||||
const keys = {};
|
const keys = {};
|
||||||
for (const item of rawChannelKeys) {
|
for (const item of rawChannelKeys) {
|
||||||
try {
|
try {
|
||||||
const bundleJson = await window.cryptoAPI.privateDecrypt(privateKey, item.encrypted_key_bundle);
|
const bundleJson = await window.cryptoAPI.privateDecrypt(privateKey, item.encrypted_key_bundle);
|
||||||
const bundle = JSON.parse(bundleJson);
|
Object.assign(keys, JSON.parse(bundleJson));
|
||||||
Object.assign(keys, bundle);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed to decrypt keys for channel ${item.channel_id}`, e);
|
console.error(`Failed to decrypt keys for channel ${item.channel_id}`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setChannelKeys(keys);
|
setChannelKeys(keys);
|
||||||
};
|
}
|
||||||
|
|
||||||
decryptKeys();
|
decryptKeys();
|
||||||
}, [rawChannelKeys]);
|
}, [rawChannelKeys]);
|
||||||
|
|
||||||
// Auto-select first text channel when channels load
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeChannel && channels.length > 0) {
|
if (activeChannel || channels.length === 0) return;
|
||||||
const firstTextChannel = channels.find(c => c.type === 'text');
|
const firstTextChannel = channels.find(c => c.type === 'text');
|
||||||
if (firstTextChannel) {
|
if (firstTextChannel) {
|
||||||
setActiveChannel(firstTextChannel._id);
|
setActiveChannel(firstTextChannel._id);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, [channels, activeChannel]);
|
}, [channels, activeChannel]);
|
||||||
|
|
||||||
const openDM = useCallback(async (targetUserId, targetUsername) => {
|
const openDM = useCallback(async (targetUserId, targetUsername) => {
|
||||||
const uid = localStorage.getItem('userId');
|
const uid = localStorage.getItem('userId');
|
||||||
const privateKey = sessionStorage.getItem('privateKey');
|
|
||||||
if (!uid) return;
|
if (!uid) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Find or create the DM channel
|
|
||||||
const { channelId, created } = await convex.mutation(api.dms.openDM, {
|
const { channelId, created } = await convex.mutation(api.dms.openDM, {
|
||||||
userId: uid,
|
userId: uid,
|
||||||
targetUserId
|
targetUserId
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. If newly created, generate + distribute an AES key for both users
|
|
||||||
if (created) {
|
if (created) {
|
||||||
const keyBytes = new Uint8Array(32);
|
const keyBytes = new Uint8Array(32);
|
||||||
crypto.getRandomValues(keyBytes);
|
crypto.getRandomValues(keyBytes);
|
||||||
const keyHex = Array.from(keyBytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
const keyHex = Array.from(keyBytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
// Fetch both users' public keys
|
|
||||||
const allUsers = await convex.query(api.auth.getPublicKeys, {});
|
const allUsers = await convex.query(api.auth.getPublicKeys, {});
|
||||||
const participants = allUsers.filter(u => u.id === uid || u.id === targetUserId);
|
const participants = allUsers.filter(u => u.id === uid || u.id === targetUserId);
|
||||||
|
|
||||||
@@ -117,10 +103,8 @@ const Chat = () => {
|
|||||||
if (batchKeys.length > 0) {
|
if (batchKeys.length > 0) {
|
||||||
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
|
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
|
||||||
}
|
}
|
||||||
// Channel keys will auto-update via reactive query
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Set active DM and switch to me view
|
|
||||||
setActiveDMChannel({ channel_id: channelId, other_username: targetUsername });
|
setActiveDMChannel({ channel_id: channelId, other_username: targetUsername });
|
||||||
setView('me');
|
setView('me');
|
||||||
|
|
||||||
@@ -129,13 +113,10 @@ const Chat = () => {
|
|||||||
}
|
}
|
||||||
}, [convex]);
|
}, [convex]);
|
||||||
|
|
||||||
// Helper to get active channel object
|
|
||||||
const activeChannelObj = channels.find(c => c._id === activeChannel);
|
const activeChannelObj = channels.find(c => c._id === activeChannel);
|
||||||
|
|
||||||
const { room, voiceStates } = useVoice();
|
const { room, voiceStates } = useVoice();
|
||||||
|
|
||||||
// Determine what to render in the main area
|
function renderMainContent() {
|
||||||
const renderMainContent = () => {
|
|
||||||
if (view === 'me') {
|
if (view === 'me') {
|
||||||
if (activeDMChannel) {
|
if (activeDMChannel) {
|
||||||
return (
|
return (
|
||||||
@@ -173,7 +154,7 @@ const Chat = () => {
|
|||||||
<p>Click the <b>+</b> in the sidebar to create your first encrypted channel.</p>
|
<p>Click the <b>+</b> in the sidebar to create your first encrypted channel.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
@@ -184,9 +165,7 @@ const Chat = () => {
|
|||||||
username={username}
|
username={username}
|
||||||
channelKeys={channelKeys}
|
channelKeys={channelKeys}
|
||||||
view={view}
|
view={view}
|
||||||
onViewChange={(v) => {
|
onViewChange={setView}
|
||||||
setView(v);
|
|
||||||
}}
|
|
||||||
onOpenDM={openDM}
|
onOpenDM={openDM}
|
||||||
activeDMChannel={activeDMChannel}
|
activeDMChannel={activeDMChannel}
|
||||||
setActiveDMChannel={setActiveDMChannel}
|
setActiveDMChannel={setActiveDMChannel}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { Link, useNavigate } from 'react-router-dom';
|
|||||||
import { useConvex } from 'convex/react';
|
import { useConvex } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
|
|
||||||
|
async function decryptEncryptedField(encryptedJson, keyHex) {
|
||||||
|
const obj = JSON.parse(encryptedJson);
|
||||||
|
return window.cryptoAPI.decryptData(obj.content, keyHex, obj.iv, obj.tag);
|
||||||
|
}
|
||||||
|
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -19,15 +24,12 @@ const Login = () => {
|
|||||||
try {
|
try {
|
||||||
console.log('Starting login for:', username);
|
console.log('Starting login for:', username);
|
||||||
|
|
||||||
// 1. Get Salt (via Convex query)
|
|
||||||
const { salt } = await convex.query(api.auth.getSalt, { username });
|
const { salt } = await convex.query(api.auth.getSalt, { username });
|
||||||
console.log('Got salt');
|
console.log('Got salt');
|
||||||
|
|
||||||
// 2. Derive Keys (DEK, DAK)
|
|
||||||
const { dek, dak } = await window.cryptoAPI.deriveAuthKeys(password, salt);
|
const { dek, dak } = await window.cryptoAPI.deriveAuthKeys(password, salt);
|
||||||
console.log('Derived keys');
|
console.log('Derived keys');
|
||||||
|
|
||||||
// 3. Verify with Convex
|
|
||||||
const verifyData = await convex.mutation(api.auth.verifyUser, { username, dak });
|
const verifyData = await convex.mutation(api.auth.verifyUser, { username, dak });
|
||||||
|
|
||||||
if (verifyData.error) {
|
if (verifyData.error) {
|
||||||
@@ -43,39 +45,15 @@ const Login = () => {
|
|||||||
console.error('MISSING USERID IN VERIFY RESPONSE!', verifyData);
|
console.error('MISSING USERID IN VERIFY RESPONSE!', verifyData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Decrypt Master Key (using DEK)
|
|
||||||
console.log('Decrypting Master Key...');
|
console.log('Decrypting Master Key...');
|
||||||
const encryptedMKObj = JSON.parse(verifyData.encryptedMK);
|
const mkHex = await decryptEncryptedField(verifyData.encryptedMK, dek);
|
||||||
const mkHex = await window.cryptoAPI.decryptData(
|
|
||||||
encryptedMKObj.content,
|
|
||||||
dek,
|
|
||||||
encryptedMKObj.iv,
|
|
||||||
encryptedMKObj.tag
|
|
||||||
);
|
|
||||||
|
|
||||||
// 5. Decrypt Private Keys (using MK)
|
|
||||||
console.log('Decrypting Private Keys...');
|
console.log('Decrypting Private Keys...');
|
||||||
const encryptedPrivateKeysObj = JSON.parse(verifyData.encryptedPrivateKeys);
|
const encryptedPrivateKeysObj = JSON.parse(verifyData.encryptedPrivateKeys);
|
||||||
|
|
||||||
// Decrypt Ed25519 Signing Key
|
const signingKey = await decryptEncryptedField(JSON.stringify(encryptedPrivateKeysObj.ed), mkHex);
|
||||||
const edPrivObj = encryptedPrivateKeysObj.ed;
|
const rsaPriv = await decryptEncryptedField(JSON.stringify(encryptedPrivateKeysObj.rsa), mkHex);
|
||||||
const signingKey = await window.cryptoAPI.decryptData(
|
|
||||||
edPrivObj.content,
|
|
||||||
mkHex,
|
|
||||||
edPrivObj.iv,
|
|
||||||
edPrivObj.tag
|
|
||||||
);
|
|
||||||
|
|
||||||
// Decrypt RSA Private Key (Identity Key)
|
|
||||||
const rsaPrivObj = encryptedPrivateKeysObj.rsa;
|
|
||||||
const rsaPriv = await window.cryptoAPI.decryptData(
|
|
||||||
rsaPrivObj.content,
|
|
||||||
mkHex,
|
|
||||||
rsaPrivObj.iv,
|
|
||||||
rsaPrivObj.tag
|
|
||||||
);
|
|
||||||
|
|
||||||
// Store Keys in Session (Memory-like) storage
|
|
||||||
sessionStorage.setItem('signingKey', signingKey);
|
sessionStorage.setItem('signingKey', signingKey);
|
||||||
sessionStorage.setItem('privateKey', rsaPriv);
|
sessionStorage.setItem('privateKey', rsaPriv);
|
||||||
console.log('Keys decrypted and stored in session.');
|
console.log('Keys decrypted and stored in session.');
|
||||||
@@ -85,7 +63,6 @@ const Login = () => {
|
|||||||
localStorage.setItem('publicKey', verifyData.publicKey);
|
localStorage.setItem('publicKey', verifyData.publicKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify immediate read back
|
|
||||||
console.log('Immediate localStorage read check:', localStorage.getItem('userId'));
|
console.log('Immediate localStorage read check:', localStorage.getItem('userId'));
|
||||||
|
|
||||||
navigate('/chat');
|
navigate('/chat');
|
||||||
|
|||||||
@@ -3,12 +3,19 @@ import { Link, useNavigate, useLocation } from 'react-router-dom';
|
|||||||
import { useConvex } from 'convex/react';
|
import { useConvex } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
|
|
||||||
|
function parseInviteParams(input) {
|
||||||
|
const codeMatch = input.match(/[?&]code=([^&]+)/);
|
||||||
|
const keyMatch = input.match(/[?&]key=([^&]+)/);
|
||||||
|
if (codeMatch && keyMatch) return { code: codeMatch[1], secret: keyMatch[1] };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const Register = () => {
|
const Register = () => {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [inviteKeys, setInviteKeys] = useState(null); // { channelId: keyHex }
|
const [inviteKeys, setInviteKeys] = useState(null);
|
||||||
const [inviteLinkInput, setInviteLinkInput] = useState('');
|
const [inviteLinkInput, setInviteLinkInput] = useState('');
|
||||||
const [activeInviteCode, setActiveInviteCode] = useState(null);
|
const [activeInviteCode, setActiveInviteCode] = useState(null);
|
||||||
|
|
||||||
@@ -16,7 +23,6 @@ const Register = () => {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const convex = useConvex();
|
const convex = useConvex();
|
||||||
|
|
||||||
// Helper to process code/key
|
|
||||||
const processInvite = async (code, secret) => {
|
const processInvite = async (code, secret) => {
|
||||||
if (!window.cryptoAPI) {
|
if (!window.cryptoAPI) {
|
||||||
setError("Critical Error: Secure Crypto API missing. Run in Electron.");
|
setError("Critical Error: Secure Crypto API missing. Run in Electron.");
|
||||||
@@ -24,16 +30,13 @@ const Register = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch Invite via Convex
|
|
||||||
const result = await convex.query(api.invites.use, { code });
|
const result = await convex.query(api.invites.use, { code });
|
||||||
if (result.error) throw new Error(result.error);
|
if (result.error) throw new Error(result.error);
|
||||||
const { encryptedPayload } = result;
|
|
||||||
|
|
||||||
// Decrypt Payload
|
const blob = JSON.parse(result.encryptedPayload);
|
||||||
const blob = JSON.parse(encryptedPayload);
|
|
||||||
const decrypted = await window.cryptoAPI.decryptData(blob.c, secret, blob.iv, blob.t);
|
const decrypted = await window.cryptoAPI.decryptData(blob.c, secret, blob.iv, blob.t);
|
||||||
|
|
||||||
const keys = JSON.parse(decrypted);
|
const keys = JSON.parse(decrypted);
|
||||||
|
|
||||||
console.log('Invite keys decrypted successfully:', Object.keys(keys).length);
|
console.log('Invite keys decrypted successfully:', Object.keys(keys).length);
|
||||||
setInviteKeys(keys);
|
setInviteKeys(keys);
|
||||||
setActiveInviteCode(code);
|
setActiveInviteCode(code);
|
||||||
@@ -44,7 +47,6 @@ const Register = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle Invite Link parsing from URL
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const code = params.get('code');
|
const code = params.get('code');
|
||||||
@@ -57,18 +59,12 @@ const Register = () => {
|
|||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
const handleManualInvite = () => {
|
const handleManualInvite = () => {
|
||||||
try {
|
const parsed = parseInviteParams(inviteLinkInput);
|
||||||
const codeMatch = inviteLinkInput.match(/[?&]code=([^&]+)/);
|
if (parsed) {
|
||||||
const keyMatch = inviteLinkInput.match(/[?&]key=([^&]+)/);
|
processInvite(parsed.code, parsed.secret);
|
||||||
|
|
||||||
if (codeMatch && keyMatch) {
|
|
||||||
processInvite(codeMatch[1], keyMatch[1]);
|
|
||||||
} else {
|
} else {
|
||||||
setError("Invalid invite link format.");
|
setError("Invalid invite link format.");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
setError("Invalid URL.");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRegister = async (e) => {
|
const handleRegister = async (e) => {
|
||||||
@@ -79,36 +75,18 @@ const Register = () => {
|
|||||||
try {
|
try {
|
||||||
console.log('Starting registration for:', username);
|
console.log('Starting registration for:', username);
|
||||||
|
|
||||||
// 1. Generate Salt and Master Key (MK)
|
|
||||||
const salt = await window.cryptoAPI.randomBytes(16);
|
const salt = await window.cryptoAPI.randomBytes(16);
|
||||||
const mk = await window.cryptoAPI.randomBytes(32);
|
const mk = await window.cryptoAPI.randomBytes(32);
|
||||||
|
|
||||||
console.log('Generated Salt and MK');
|
|
||||||
|
|
||||||
// 2. Derive Keys (DEK, DAK)
|
|
||||||
const { dek, dak } = await window.cryptoAPI.deriveAuthKeys(password, salt);
|
const { dek, dak } = await window.cryptoAPI.deriveAuthKeys(password, salt);
|
||||||
console.log('Derived keys');
|
const encryptedMK = JSON.stringify(await window.cryptoAPI.encryptData(mk, dek));
|
||||||
|
|
||||||
// 3. Encrypt MK with DEK
|
|
||||||
const encryptedMKObj = await window.cryptoAPI.encryptData(mk, dek);
|
|
||||||
const encryptedMK = JSON.stringify(encryptedMKObj);
|
|
||||||
|
|
||||||
// 4. Hash DAK for Auth Proof
|
|
||||||
const hak = await window.cryptoAPI.sha256(dak);
|
const hak = await window.cryptoAPI.sha256(dak);
|
||||||
|
|
||||||
// 5. Generate Key Pairs
|
|
||||||
const keys = await window.cryptoAPI.generateKeys();
|
const keys = await window.cryptoAPI.generateKeys();
|
||||||
|
|
||||||
// 6. Encrypt Private Keys with MK
|
|
||||||
const encryptedRsaPriv = await window.cryptoAPI.encryptData(keys.rsaPriv, mk);
|
|
||||||
const encryptedEdPriv = await window.cryptoAPI.encryptData(keys.edPriv, mk);
|
|
||||||
|
|
||||||
const encryptedPrivateKeys = JSON.stringify({
|
const encryptedPrivateKeys = JSON.stringify({
|
||||||
rsa: encryptedRsaPriv,
|
rsa: await window.cryptoAPI.encryptData(keys.rsaPriv, mk),
|
||||||
ed: encryptedEdPriv
|
ed: await window.cryptoAPI.encryptData(keys.edPriv, mk)
|
||||||
});
|
});
|
||||||
|
|
||||||
// 7. Register via Convex
|
|
||||||
const data = await convex.mutation(api.auth.createUserWithProfile, {
|
const data = await convex.mutation(api.auth.createUserWithProfile, {
|
||||||
username,
|
username,
|
||||||
salt,
|
salt,
|
||||||
@@ -120,34 +98,25 @@ const Register = () => {
|
|||||||
inviteCode: activeInviteCode || undefined
|
inviteCode: activeInviteCode || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) throw new Error(data.error);
|
||||||
throw new Error(data.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Registration successful:', data);
|
console.log('Registration successful:', data);
|
||||||
|
|
||||||
// 8. Upload Invite Keys (If present)
|
|
||||||
if (inviteKeys && data.userId) {
|
if (inviteKeys && data.userId) {
|
||||||
console.log('Uploading invite keys...');
|
console.log('Uploading invite keys...');
|
||||||
const batchKeys = [];
|
const batchKeys = await Promise.all(
|
||||||
for (const [channelId, channelKeyHex] of Object.entries(inviteKeys)) {
|
Object.entries(inviteKeys).map(async ([channelId, channelKeyHex]) => {
|
||||||
try {
|
|
||||||
const payload = JSON.stringify({ [channelId]: channelKeyHex });
|
const payload = JSON.stringify({ [channelId]: channelKeyHex });
|
||||||
const encryptedKeyBundle = await window.cryptoAPI.publicEncrypt(keys.rsaPub, payload);
|
const encryptedKeyBundle = await window.cryptoAPI.publicEncrypt(keys.rsaPub, payload);
|
||||||
|
return { channelId, userId: data.userId, encryptedKeyBundle, keyVersion: 1 };
|
||||||
|
}).map(p => p.catch(err => {
|
||||||
|
console.error('Failed to encrypt key for channel:', err);
|
||||||
|
return null;
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
batchKeys.push({
|
const validKeys = batchKeys.filter(Boolean);
|
||||||
channelId,
|
if (validKeys.length > 0) {
|
||||||
userId: data.userId,
|
await convex.mutation(api.channelKeys.uploadKeys, { keys: validKeys });
|
||||||
encryptedKeyBundle,
|
|
||||||
keyVersion: 1
|
|
||||||
});
|
|
||||||
} catch (keyErr) {
|
|
||||||
console.error('Failed to encrypt key for channel:', channelId, keyErr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (batchKeys.length > 0) {
|
|
||||||
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
|
|
||||||
console.log('Uploaded invite keys');
|
console.log('Uploaded invite keys');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,10 +139,8 @@ const Register = () => {
|
|||||||
</div>
|
</div>
|
||||||
{error && <div style={{ color: 'red', marginBottom: 10, textAlign: 'center' }}>{error}</div>}
|
{error && <div style={{ color: 'red', marginBottom: 10, textAlign: 'center' }}>{error}</div>}
|
||||||
|
|
||||||
{/* Manual Invite Input - Fallback for Desktop App */}
|
|
||||||
{!inviteKeys && (
|
{!inviteKeys && (
|
||||||
<div style={{ marginBottom: '15px' }}>
|
<div style={{ marginBottom: '15px', display: 'flex' }}>
|
||||||
<div style={{ display: 'flex' }}>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Paste Invite Link Here..."
|
placeholder="Paste Invite Link Here..."
|
||||||
@@ -185,9 +152,6 @@ const Register = () => {
|
|||||||
Apply
|
Apply
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{inviteKeys ? (
|
{inviteKeys ? (
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { query, mutation } from "./_generated/server";
|
import { query, mutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
|
async function sha256Hex(input: string): Promise<string> {
|
||||||
|
const buffer = await crypto.subtle.digest(
|
||||||
|
"SHA-256",
|
||||||
|
new TextEncoder().encode(input)
|
||||||
|
);
|
||||||
|
return Array.from(new Uint8Array(buffer))
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
// Get salt for a username (returns fake salt for non-existent users)
|
// Get salt for a username (returns fake salt for non-existent users)
|
||||||
export const getSalt = query({
|
export const getSalt = query({
|
||||||
args: { username: v.string() },
|
args: { username: v.string() },
|
||||||
@@ -16,15 +26,7 @@ export const getSalt = query({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate deterministic fake salt for non-existent users (privacy)
|
// Generate deterministic fake salt for non-existent users (privacy)
|
||||||
// Simple HMAC-like approach using username
|
const fakeSalt = await sha256Hex("SERVER_SECRET_KEY" + args.username);
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const data = encoder.encode("SERVER_SECRET_KEY" + args.username);
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
||||||
const hashArray = new Uint8Array(hashBuffer);
|
|
||||||
const fakeSalt = Array.from(hashArray)
|
|
||||||
.map((b) => b.toString(16).padStart(2, "0"))
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
return { salt: fakeSalt };
|
return { salt: fakeSalt };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -55,14 +57,7 @@ export const verifyUser = mutation({
|
|||||||
return { error: "Invalid credentials" };
|
return { error: "Invalid credentials" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash the DAK with SHA-256 and compare
|
const hashedDAK = await sha256Hex(args.dak);
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const dakBuffer = encoder.encode(args.dak);
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", dakBuffer);
|
|
||||||
const hashArray = new Uint8Array(hashBuffer);
|
|
||||||
const hashedDAK = Array.from(hashArray)
|
|
||||||
.map((b) => b.toString(16).padStart(2, "0"))
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
if (hashedDAK === user.hashedAuthKey) {
|
if (hashedDAK === user.hashedAuthKey) {
|
||||||
return {
|
return {
|
||||||
@@ -95,7 +90,6 @@ export const createUserWithProfile = mutation({
|
|||||||
v.object({ error: v.string() })
|
v.object({ error: v.string() })
|
||||||
),
|
),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
// Check if username is taken
|
|
||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.query("userProfiles")
|
.query("userProfiles")
|
||||||
.withIndex("by_username", (q) => q.eq("username", args.username))
|
.withIndex("by_username", (q) => q.eq("username", args.username))
|
||||||
@@ -105,17 +99,14 @@ export const createUserWithProfile = mutation({
|
|||||||
return { error: "Username taken" };
|
return { error: "Username taken" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count existing users
|
const isFirstUser =
|
||||||
const allUsers = await ctx.db.query("userProfiles").collect();
|
(await ctx.db.query("userProfiles").first()) === null;
|
||||||
const userCount = allUsers.length;
|
|
||||||
|
|
||||||
// Enforce invite code for non-first users
|
if (!isFirstUser) {
|
||||||
if (userCount > 0) {
|
|
||||||
if (!args.inviteCode) {
|
if (!args.inviteCode) {
|
||||||
return { error: "Invite code required" };
|
return { error: "Invite code required" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate invite
|
|
||||||
const invite = await ctx.db
|
const invite = await ctx.db
|
||||||
.query("invites")
|
.query("invites")
|
||||||
.withIndex("by_code", (q) => q.eq("code", args.inviteCode))
|
.withIndex("by_code", (q) => q.eq("code", args.inviteCode))
|
||||||
@@ -137,11 +128,9 @@ export const createUserWithProfile = mutation({
|
|||||||
return { error: "Invite max uses reached" };
|
return { error: "Invite max uses reached" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment invite usage
|
|
||||||
await ctx.db.patch(invite._id, { uses: invite.uses + 1 });
|
await ctx.db.patch(invite._id, { uses: invite.uses + 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user profile
|
|
||||||
const userId = await ctx.db.insert("userProfiles", {
|
const userId = await ctx.db.insert("userProfiles", {
|
||||||
username: args.username,
|
username: args.username,
|
||||||
clientSalt: args.salt,
|
clientSalt: args.salt,
|
||||||
@@ -150,12 +139,10 @@ export const createUserWithProfile = mutation({
|
|||||||
publicIdentityKey: args.publicKey,
|
publicIdentityKey: args.publicKey,
|
||||||
publicSigningKey: args.signingKey,
|
publicSigningKey: args.signingKey,
|
||||||
encryptedPrivateKeys: args.encryptedPrivateKeys,
|
encryptedPrivateKeys: args.encryptedPrivateKeys,
|
||||||
isAdmin: userCount === 0,
|
isAdmin: isFirstUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
// First user bootstrap: create Owner + @everyone roles if they don't exist
|
if (isFirstUser) {
|
||||||
if (userCount === 0) {
|
|
||||||
// Create @everyone role
|
|
||||||
const everyoneRoleId = await ctx.db.insert("roles", {
|
const everyoneRoleId = await ctx.db.insert("roles", {
|
||||||
name: "@everyone",
|
name: "@everyone",
|
||||||
color: "#99aab5",
|
color: "#99aab5",
|
||||||
@@ -168,7 +155,6 @@ export const createUserWithProfile = mutation({
|
|||||||
isHoist: false,
|
isHoist: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create Owner role
|
|
||||||
const ownerRoleId = await ctx.db.insert("roles", {
|
const ownerRoleId = await ctx.db.insert("roles", {
|
||||||
name: "Owner",
|
name: "Owner",
|
||||||
color: "#e91e63",
|
color: "#e91e63",
|
||||||
@@ -183,11 +169,9 @@ export const createUserWithProfile = mutation({
|
|||||||
isHoist: true,
|
isHoist: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assign both roles to first user
|
|
||||||
await ctx.db.insert("userRoles", { userId, roleId: everyoneRoleId });
|
await ctx.db.insert("userRoles", { userId, roleId: everyoneRoleId });
|
||||||
await ctx.db.insert("userRoles", { userId, roleId: ownerRoleId });
|
await ctx.db.insert("userRoles", { userId, roleId: ownerRoleId });
|
||||||
} else {
|
} else {
|
||||||
// Assign @everyone role to new user
|
|
||||||
const everyoneRole = await ctx.db
|
const everyoneRole = await ctx.db
|
||||||
.query("roles")
|
.query("roles")
|
||||||
.filter((q) => q.eq(q.field("name"), "@everyone"))
|
.filter((q) => q.eq(q.field("name"), "@everyone"))
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
import { query, mutation } from "./_generated/server";
|
import { query, mutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
import { GenericMutationCtx } from "convex/server";
|
||||||
|
import { DataModel, Id } from "./_generated/dataModel";
|
||||||
|
|
||||||
|
type TableWithChannelIndex =
|
||||||
|
| "channelKeys"
|
||||||
|
| "dmParticipants"
|
||||||
|
| "typingIndicators"
|
||||||
|
| "voiceStates";
|
||||||
|
|
||||||
|
async function deleteByChannel(
|
||||||
|
ctx: GenericMutationCtx<DataModel>,
|
||||||
|
table: TableWithChannelIndex,
|
||||||
|
channelId: Id<"channels">
|
||||||
|
) {
|
||||||
|
const docs = await (ctx.db.query(table) as any)
|
||||||
|
.withIndex("by_channel", (q: any) => q.eq("channelId", channelId))
|
||||||
|
.collect();
|
||||||
|
for (const doc of docs) {
|
||||||
|
await ctx.db.delete(doc._id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// List all non-DM channels
|
// List all non-DM channels
|
||||||
export const list = query({
|
export const list = query({
|
||||||
@@ -49,7 +70,6 @@ export const create = mutation({
|
|||||||
throw new Error("Channel name required");
|
throw new Error("Channel name required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate name
|
|
||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.query("channels")
|
.query("channels")
|
||||||
.withIndex("by_name", (q) => q.eq("name", args.name))
|
.withIndex("by_name", (q) => q.eq("name", args.name))
|
||||||
@@ -105,13 +125,12 @@ export const remove = mutation({
|
|||||||
throw new Error("Channel not found");
|
throw new Error("Channel not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete messages
|
// Delete reactions for all messages in this channel
|
||||||
const messages = await ctx.db
|
const messages = await ctx.db
|
||||||
.query("messages")
|
.query("messages")
|
||||||
.withIndex("by_channel", (q) => q.eq("channelId", args.id))
|
.withIndex("by_channel", (q) => q.eq("channelId", args.id))
|
||||||
.collect();
|
.collect();
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
// Delete reactions for this message
|
|
||||||
const reactions = await ctx.db
|
const reactions = await ctx.db
|
||||||
.query("messageReactions")
|
.query("messageReactions")
|
||||||
.withIndex("by_message", (q) => q.eq("messageId", msg._id))
|
.withIndex("by_message", (q) => q.eq("messageId", msg._id))
|
||||||
@@ -122,43 +141,11 @@ export const remove = mutation({
|
|||||||
await ctx.db.delete(msg._id);
|
await ctx.db.delete(msg._id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete channel keys
|
await deleteByChannel(ctx, "channelKeys", args.id);
|
||||||
const keys = await ctx.db
|
await deleteByChannel(ctx, "dmParticipants", args.id);
|
||||||
.query("channelKeys")
|
await deleteByChannel(ctx, "typingIndicators", args.id);
|
||||||
.withIndex("by_channel", (q) => q.eq("channelId", args.id))
|
await deleteByChannel(ctx, "voiceStates", args.id);
|
||||||
.collect();
|
|
||||||
for (const key of keys) {
|
|
||||||
await ctx.db.delete(key._id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete DM participants
|
|
||||||
const dmParts = await ctx.db
|
|
||||||
.query("dmParticipants")
|
|
||||||
.withIndex("by_channel", (q) => q.eq("channelId", args.id))
|
|
||||||
.collect();
|
|
||||||
for (const dp of dmParts) {
|
|
||||||
await ctx.db.delete(dp._id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete typing indicators
|
|
||||||
const typing = await ctx.db
|
|
||||||
.query("typingIndicators")
|
|
||||||
.withIndex("by_channel", (q) => q.eq("channelId", args.id))
|
|
||||||
.collect();
|
|
||||||
for (const t of typing) {
|
|
||||||
await ctx.db.delete(t._id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete voice states
|
|
||||||
const voiceStates = await ctx.db
|
|
||||||
.query("voiceStates")
|
|
||||||
.withIndex("by_channel", (q) => q.eq("channelId", args.id))
|
|
||||||
.collect();
|
|
||||||
for (const vs of voiceStates) {
|
|
||||||
await ctx.db.delete(vs._id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete channel itself
|
|
||||||
await ctx.db.delete(args.id);
|
await ctx.db.delete(args.id);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { query, mutation } from "./_generated/server";
|
import { query, mutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
// Find-or-create DM channel between two users
|
|
||||||
export const openDM = mutation({
|
export const openDM = mutation({
|
||||||
args: {
|
args: {
|
||||||
userId: v.id("userProfiles"),
|
userId: v.id("userProfiles"),
|
||||||
@@ -16,11 +15,9 @@ export const openDM = mutation({
|
|||||||
throw new Error("Cannot DM yourself");
|
throw new Error("Cannot DM yourself");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deterministic channel name
|
|
||||||
const sorted = [args.userId, args.targetUserId].sort();
|
const sorted = [args.userId, args.targetUserId].sort();
|
||||||
const dmName = `dm-${sorted[0]}-${sorted[1]}`;
|
const dmName = `dm-${sorted[0]}-${sorted[1]}`;
|
||||||
|
|
||||||
// Check if already exists
|
|
||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.query("channels")
|
.query("channels")
|
||||||
.withIndex("by_name", (q) => q.eq("name", dmName))
|
.withIndex("by_name", (q) => q.eq("name", dmName))
|
||||||
@@ -30,27 +27,20 @@ export const openDM = mutation({
|
|||||||
return { channelId: existing._id, created: false };
|
return { channelId: existing._id, created: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create DM channel
|
|
||||||
const channelId = await ctx.db.insert("channels", {
|
const channelId = await ctx.db.insert("channels", {
|
||||||
name: dmName,
|
name: dmName,
|
||||||
type: "dm",
|
type: "dm",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add participants
|
await Promise.all([
|
||||||
await ctx.db.insert("dmParticipants", {
|
ctx.db.insert("dmParticipants", { channelId, userId: args.userId }),
|
||||||
channelId,
|
ctx.db.insert("dmParticipants", { channelId, userId: args.targetUserId }),
|
||||||
userId: args.userId,
|
]);
|
||||||
});
|
|
||||||
await ctx.db.insert("dmParticipants", {
|
|
||||||
channelId,
|
|
||||||
userId: args.targetUserId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { channelId, created: true };
|
return { channelId, created: true };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// List user's DM channels with other user info
|
|
||||||
export const listDMs = query({
|
export const listDMs = query({
|
||||||
args: { userId: v.id("userProfiles") },
|
args: { userId: v.id("userProfiles") },
|
||||||
returns: v.array(
|
returns: v.array(
|
||||||
@@ -62,43 +52,36 @@ export const listDMs = query({
|
|||||||
})
|
})
|
||||||
),
|
),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
// Get all DM participations for this user
|
|
||||||
const myParticipations = await ctx.db
|
const myParticipations = await ctx.db
|
||||||
.query("dmParticipants")
|
.query("dmParticipants")
|
||||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
const result: Array<{
|
const results = await Promise.all(
|
||||||
channel_id: typeof myParticipations[0]["channelId"];
|
myParticipations.map(async (part) => {
|
||||||
channel_name: string;
|
|
||||||
other_user_id: string;
|
|
||||||
other_username: string;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
for (const part of myParticipations) {
|
|
||||||
const channel = await ctx.db.get(part.channelId);
|
const channel = await ctx.db.get(part.channelId);
|
||||||
if (!channel || channel.type !== "dm") continue;
|
if (!channel || channel.type !== "dm") return null;
|
||||||
|
|
||||||
// Find other participant
|
|
||||||
const otherParts = await ctx.db
|
const otherParts = await ctx.db
|
||||||
.query("dmParticipants")
|
.query("dmParticipants")
|
||||||
.withIndex("by_channel", (q) => q.eq("channelId", part.channelId))
|
.withIndex("by_channel", (q) => q.eq("channelId", part.channelId))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
const otherPart = otherParts.find((p) => p.userId !== args.userId);
|
const otherPart = otherParts.find((p) => p.userId !== args.userId);
|
||||||
if (!otherPart) continue;
|
if (!otherPart) return null;
|
||||||
|
|
||||||
const otherUser = await ctx.db.get(otherPart.userId);
|
const otherUser = await ctx.db.get(otherPart.userId);
|
||||||
if (!otherUser) continue;
|
if (!otherUser) return null;
|
||||||
|
|
||||||
result.push({
|
return {
|
||||||
channel_id: part.channelId,
|
channel_id: part.channelId,
|
||||||
channel_name: channel.name,
|
channel_name: channel.name,
|
||||||
other_user_id: otherUser._id,
|
other_user_id: otherUser._id as string,
|
||||||
other_username: otherUser.username,
|
other_username: otherUser.username,
|
||||||
});
|
};
|
||||||
}
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return result;
|
return results.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,47 +1,36 @@
|
|||||||
import { query, mutation } from "./_generated/server";
|
import { query, mutation } from "./_generated/server";
|
||||||
|
import { paginationOptsValidator } from "convex/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
// List recent messages for a channel with reactions + username
|
|
||||||
export const list = query({
|
export const list = query({
|
||||||
args: {
|
args: {
|
||||||
|
paginationOpts: paginationOptsValidator,
|
||||||
channelId: v.id("channels"),
|
channelId: v.id("channels"),
|
||||||
userId: v.optional(v.id("userProfiles")),
|
userId: v.optional(v.id("userProfiles")),
|
||||||
},
|
},
|
||||||
returns: v.array(v.any()),
|
returns: v.any(),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const messages = await ctx.db
|
const result = await ctx.db
|
||||||
.query("messages")
|
.query("messages")
|
||||||
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
|
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
|
||||||
.order("desc")
|
.order("desc")
|
||||||
.take(50);
|
.paginate(args.paginationOpts);
|
||||||
|
|
||||||
// Reverse to get chronological order
|
const enrichedPage = await Promise.all(
|
||||||
const chronological = messages.reverse();
|
result.page.map(async (msg) => {
|
||||||
|
|
||||||
// Enrich with username, signing key, and reactions
|
|
||||||
const enriched = await Promise.all(
|
|
||||||
chronological.map(async (msg) => {
|
|
||||||
// Get sender info
|
|
||||||
const sender = await ctx.db.get(msg.senderId);
|
const sender = await ctx.db.get(msg.senderId);
|
||||||
|
|
||||||
// Get reactions for this message
|
|
||||||
const reactionDocs = await ctx.db
|
const reactionDocs = await ctx.db
|
||||||
.query("messageReactions")
|
.query("messageReactions")
|
||||||
.withIndex("by_message", (q) => q.eq("messageId", msg._id))
|
.withIndex("by_message", (q) => q.eq("messageId", msg._id))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Aggregate reactions
|
const reactions: Record<string, { count: number; me: boolean }> = {};
|
||||||
const reactions: Record<
|
|
||||||
string,
|
|
||||||
{ count: number; me: boolean }
|
|
||||||
> = {};
|
|
||||||
for (const r of reactionDocs) {
|
for (const r of reactionDocs) {
|
||||||
if (!reactions[r.emoji]) {
|
const entry = (reactions[r.emoji] ??= { count: 0, me: false });
|
||||||
reactions[r.emoji] = { count: 0, me: false };
|
entry.count++;
|
||||||
}
|
|
||||||
reactions[r.emoji].count++;
|
|
||||||
if (args.userId && r.userId === args.userId) {
|
if (args.userId && r.userId === args.userId) {
|
||||||
reactions[r.emoji].me = true;
|
entry.me = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,17 +45,15 @@ export const list = query({
|
|||||||
created_at: new Date(msg._creationTime).toISOString(),
|
created_at: new Date(msg._creationTime).toISOString(),
|
||||||
username: sender?.username || "Unknown",
|
username: sender?.username || "Unknown",
|
||||||
public_signing_key: sender?.publicSigningKey || "",
|
public_signing_key: sender?.publicSigningKey || "",
|
||||||
reactions:
|
reactions: Object.keys(reactions).length > 0 ? reactions : null,
|
||||||
Object.keys(reactions).length > 0 ? reactions : null,
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return enriched;
|
return { ...result, page: enrichedPage };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send encrypted message
|
|
||||||
export const send = mutation({
|
export const send = mutation({
|
||||||
args: {
|
args: {
|
||||||
channelId: v.id("channels"),
|
channelId: v.id("channels"),
|
||||||
@@ -86,17 +73,14 @@ export const send = mutation({
|
|||||||
signature: args.signature,
|
signature: args.signature,
|
||||||
keyVersion: args.keyVersion,
|
keyVersion: args.keyVersion,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { id };
|
return { id };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete a message
|
|
||||||
export const remove = mutation({
|
export const remove = mutation({
|
||||||
args: { id: v.id("messages") },
|
args: { id: v.id("messages") },
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
// Delete reactions first
|
|
||||||
const reactions = await ctx.db
|
const reactions = await ctx.db
|
||||||
.query("messageReactions")
|
.query("messageReactions")
|
||||||
.withIndex("by_message", (q) => q.eq("messageId", args.id))
|
.withIndex("by_message", (q) => q.eq("messageId", args.id))
|
||||||
|
|||||||
103
convex/roles.ts
103
convex/roles.ts
@@ -1,5 +1,30 @@
|
|||||||
import { query, mutation } from "./_generated/server";
|
import { query, mutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
import { GenericQueryCtx } from "convex/server";
|
||||||
|
import { DataModel, Id, Doc } from "./_generated/dataModel";
|
||||||
|
|
||||||
|
const PERMISSION_KEYS = [
|
||||||
|
"manage_channels",
|
||||||
|
"manage_roles",
|
||||||
|
"create_invite",
|
||||||
|
"embed_links",
|
||||||
|
"attach_files",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
async function getRolesForUser(
|
||||||
|
ctx: GenericQueryCtx<DataModel>,
|
||||||
|
userId: Id<"userProfiles">
|
||||||
|
): Promise<Doc<"roles">[]> {
|
||||||
|
const assignments = await ctx.db
|
||||||
|
.query("userRoles")
|
||||||
|
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const roles = await Promise.all(
|
||||||
|
assignments.map((ur) => ctx.db.get(ur.roleId))
|
||||||
|
);
|
||||||
|
return roles.filter((r): r is Doc<"roles"> => r !== null);
|
||||||
|
}
|
||||||
|
|
||||||
// List all roles
|
// List all roles
|
||||||
export const list = query({
|
export const list = query({
|
||||||
@@ -49,18 +74,17 @@ export const update = mutation({
|
|||||||
const role = await ctx.db.get(args.id);
|
const role = await ctx.db.get(args.id);
|
||||||
if (!role) throw new Error("Role not found");
|
if (!role) throw new Error("Role not found");
|
||||||
|
|
||||||
|
const { id, ...fields } = args;
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
if (args.name !== undefined) updates.name = args.name;
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
if (args.color !== undefined) updates.color = args.color;
|
if (value !== undefined) updates[key] = value;
|
||||||
if (args.permissions !== undefined) updates.permissions = args.permissions;
|
|
||||||
if (args.position !== undefined) updates.position = args.position;
|
|
||||||
if (args.isHoist !== undefined) updates.isHoist = args.isHoist;
|
|
||||||
|
|
||||||
if (Object.keys(updates).length > 0) {
|
|
||||||
await ctx.db.patch(args.id, updates);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await ctx.db.get(args.id);
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await ctx.db.patch(id, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ctx.db.get(id);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,7 +96,6 @@ export const remove = mutation({
|
|||||||
const role = await ctx.db.get(args.id);
|
const role = await ctx.db.get(args.id);
|
||||||
if (!role) throw new Error("Role not found");
|
if (!role) throw new Error("Role not found");
|
||||||
|
|
||||||
// Delete user_role assignments
|
|
||||||
const assignments = await ctx.db
|
const assignments = await ctx.db
|
||||||
.query("userRoles")
|
.query("userRoles")
|
||||||
.withIndex("by_role", (q) => q.eq("roleId", args.id))
|
.withIndex("by_role", (q) => q.eq("roleId", args.id))
|
||||||
@@ -93,30 +116,14 @@ export const listMembers = query({
|
|||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
const users = await ctx.db.query("userProfiles").collect();
|
const users = await ctx.db.query("userProfiles").collect();
|
||||||
|
|
||||||
const result = await Promise.all(
|
return await Promise.all(
|
||||||
users.map(async (user) => {
|
users.map(async (user) => ({
|
||||||
const userRoleAssignments = await ctx.db
|
|
||||||
.query("userRoles")
|
|
||||||
.withIndex("by_user", (q) => q.eq("userId", user._id))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
const roles = await Promise.all(
|
|
||||||
userRoleAssignments.map(async (ur) => {
|
|
||||||
const role = await ctx.db.get(ur.roleId);
|
|
||||||
return role;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: user._id,
|
id: user._id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
public_identity_key: user.publicIdentityKey,
|
public_identity_key: user.publicIdentityKey,
|
||||||
roles: roles.filter(Boolean),
|
roles: await getRolesForUser(ctx, user._id),
|
||||||
};
|
}))
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,7 +135,6 @@ export const assign = mutation({
|
|||||||
},
|
},
|
||||||
returns: v.object({ success: v.boolean() }),
|
returns: v.object({ success: v.boolean() }),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
// Check if already assigned
|
|
||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.query("userRoles")
|
.query("userRoles")
|
||||||
.withIndex("by_user_and_role", (q) =>
|
.withIndex("by_user_and_role", (q) =>
|
||||||
@@ -181,30 +187,21 @@ export const getMyPermissions = query({
|
|||||||
attach_files: v.boolean(),
|
attach_files: v.boolean(),
|
||||||
}),
|
}),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const userRoleAssignments = await ctx.db
|
const roles = await getRolesForUser(ctx, args.userId);
|
||||||
.query("userRoles")
|
|
||||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
const finalPerms = {
|
const finalPerms: Record<string, boolean> = {};
|
||||||
manage_channels: false,
|
for (const key of PERMISSION_KEYS) {
|
||||||
manage_roles: false,
|
finalPerms[key] = roles.some(
|
||||||
create_invite: false,
|
(role) => (role.permissions as Record<string, boolean>)?.[key]
|
||||||
embed_links: false,
|
);
|
||||||
attach_files: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const ur of userRoleAssignments) {
|
|
||||||
const role = await ctx.db.get(ur.roleId);
|
|
||||||
if (!role) continue;
|
|
||||||
const p = (role.permissions || {}) as Record<string, boolean>;
|
|
||||||
if (p.manage_channels) finalPerms.manage_channels = true;
|
|
||||||
if (p.manage_roles) finalPerms.manage_roles = true;
|
|
||||||
if (p.create_invite) finalPerms.create_invite = true;
|
|
||||||
if (p.embed_links) finalPerms.embed_links = true;
|
|
||||||
if (p.attach_files) finalPerms.attach_files = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return finalPerms;
|
return finalPerms as {
|
||||||
|
manage_channels: boolean;
|
||||||
|
manage_roles: boolean;
|
||||||
|
create_invite: boolean;
|
||||||
|
embed_links: boolean;
|
||||||
|
attach_files: boolean;
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { query, mutation, internalMutation } from "./_generated/server";
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { internal } from "./_generated/api";
|
import { internal } from "./_generated/api";
|
||||||
|
|
||||||
// Start typing indicator
|
const TYPING_TTL_MS = 6000;
|
||||||
|
|
||||||
export const startTyping = mutation({
|
export const startTyping = mutation({
|
||||||
args: {
|
args: {
|
||||||
channelId: v.id("channels"),
|
channelId: v.id("channels"),
|
||||||
@@ -11,9 +12,8 @@ export const startTyping = mutation({
|
|||||||
},
|
},
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const expiresAt = Date.now() + 6000; // 6 second TTL
|
const expiresAt = Date.now() + TYPING_TTL_MS;
|
||||||
|
|
||||||
// Upsert: check if already exists
|
|
||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.query("typingIndicators")
|
.query("typingIndicators")
|
||||||
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
|
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
|
||||||
@@ -32,14 +32,11 @@ export const startTyping = mutation({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule cleanup
|
await ctx.scheduler.runAfter(TYPING_TTL_MS, internal.typing.cleanExpired, {});
|
||||||
await ctx.scheduler.runAfter(6000, internal.typing.cleanExpired, {});
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stop typing indicator
|
|
||||||
export const stopTyping = mutation({
|
export const stopTyping = mutation({
|
||||||
args: {
|
args: {
|
||||||
channelId: v.id("channels"),
|
channelId: v.id("channels"),
|
||||||
@@ -61,7 +58,6 @@ export const stopTyping = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get typing users for a channel (reactive!)
|
|
||||||
export const getTyping = query({
|
export const getTyping = query({
|
||||||
args: { channelId: v.id("channels") },
|
args: { channelId: v.id("channels") },
|
||||||
returns: v.array(
|
returns: v.array(
|
||||||
@@ -79,21 +75,17 @@ export const getTyping = query({
|
|||||||
|
|
||||||
return indicators
|
return indicators
|
||||||
.filter((t) => t.expiresAt > now)
|
.filter((t) => t.expiresAt > now)
|
||||||
.map((t) => ({
|
.map((t) => ({ userId: t.userId, username: t.username }));
|
||||||
userId: t.userId,
|
|
||||||
username: t.username,
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Internal: clean expired typing indicators
|
|
||||||
export const cleanExpired = internalMutation({
|
export const cleanExpired = internalMutation({
|
||||||
args: {},
|
args: {},
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const all = await ctx.db.query("typingIndicators").collect();
|
const expired = await ctx.db.query("typingIndicators").collect();
|
||||||
for (const t of all) {
|
for (const t of expired) {
|
||||||
if (t.expiresAt <= now) {
|
if (t.expiresAt <= now) {
|
||||||
await ctx.db.delete(t._id);
|
await ctx.db.delete(t._id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { query, mutation } from "./_generated/server";
|
import { query, mutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
// Join voice channel
|
async function removeUserVoiceStates(ctx: any, userId: any) {
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query("voiceStates")
|
||||||
|
.withIndex("by_user", (q: any) => q.eq("userId", userId))
|
||||||
|
.collect();
|
||||||
|
for (const vs of existing) {
|
||||||
|
await ctx.db.delete(vs._id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const join = mutation({
|
export const join = mutation({
|
||||||
args: {
|
args: {
|
||||||
channelId: v.id("channels"),
|
channelId: v.id("channels"),
|
||||||
@@ -12,17 +21,8 @@ export const join = mutation({
|
|||||||
},
|
},
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
// Remove from any other voice channel first
|
await removeUserVoiceStates(ctx, args.userId);
|
||||||
const existing = await ctx.db
|
|
||||||
.query("voiceStates")
|
|
||||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for (const vs of existing) {
|
|
||||||
await ctx.db.delete(vs._id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to new channel
|
|
||||||
await ctx.db.insert("voiceStates", {
|
await ctx.db.insert("voiceStates", {
|
||||||
channelId: args.channelId,
|
channelId: args.channelId,
|
||||||
userId: args.userId,
|
userId: args.userId,
|
||||||
@@ -36,27 +36,17 @@ export const join = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Leave voice channel
|
|
||||||
export const leave = mutation({
|
export const leave = mutation({
|
||||||
args: {
|
args: {
|
||||||
userId: v.id("userProfiles"),
|
userId: v.id("userProfiles"),
|
||||||
},
|
},
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const existing = await ctx.db
|
await removeUserVoiceStates(ctx, args.userId);
|
||||||
.query("voiceStates")
|
|
||||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for (const vs of existing) {
|
|
||||||
await ctx.db.delete(vs._id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update mute/deafen/screenshare state
|
|
||||||
export const updateState = mutation({
|
export const updateState = mutation({
|
||||||
args: {
|
args: {
|
||||||
userId: v.id("userProfiles"),
|
userId: v.id("userProfiles"),
|
||||||
@@ -69,47 +59,36 @@ export const updateState = mutation({
|
|||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.query("voiceStates")
|
.query("voiceStates")
|
||||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||||
.collect();
|
.first();
|
||||||
|
|
||||||
if (existing.length > 0) {
|
if (existing) {
|
||||||
const updates: Record<string, boolean> = {};
|
const { userId: _, ...updates } = args;
|
||||||
if (args.isMuted !== undefined) updates.isMuted = args.isMuted;
|
const filtered = Object.fromEntries(
|
||||||
if (args.isDeafened !== undefined) updates.isDeafened = args.isDeafened;
|
Object.entries(updates).filter(([, val]) => val !== undefined)
|
||||||
if (args.isScreenSharing !== undefined)
|
);
|
||||||
updates.isScreenSharing = args.isScreenSharing;
|
await ctx.db.patch(existing._id, filtered);
|
||||||
|
|
||||||
await ctx.db.patch(existing[0]._id, updates);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all voice states (reactive!)
|
|
||||||
export const getAll = query({
|
export const getAll = query({
|
||||||
args: {},
|
args: {},
|
||||||
returns: v.any(),
|
returns: v.any(),
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
const states = await ctx.db.query("voiceStates").collect();
|
const states = await ctx.db.query("voiceStates").collect();
|
||||||
|
|
||||||
// Group by channel
|
const grouped: Record<string, Array<{
|
||||||
const grouped: Record<
|
|
||||||
string,
|
|
||||||
Array<{
|
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
isDeafened: boolean;
|
isDeafened: boolean;
|
||||||
isScreenSharing: boolean;
|
isScreenSharing: boolean;
|
||||||
}>
|
}>> = {};
|
||||||
> = {};
|
|
||||||
|
|
||||||
for (const s of states) {
|
for (const s of states) {
|
||||||
const channelId = s.channelId;
|
(grouped[s.channelId] ??= []).push({
|
||||||
if (!grouped[channelId]) {
|
|
||||||
grouped[channelId] = [];
|
|
||||||
}
|
|
||||||
grouped[channelId].push({
|
|
||||||
userId: s.userId,
|
userId: s.userId,
|
||||||
username: s.username,
|
username: s.username,
|
||||||
isMuted: s.isMuted,
|
isMuted: s.isMuted,
|
||||||
|
|||||||
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