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

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

View File

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

After

Width:  |  Height:  |  Size: 270 B

View File

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

After

Width:  |  Height:  |  Size: 597 B

View File

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

After

Width:  |  Height:  |  Size: 421 B

View File

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

After

Width:  |  Height:  |  Size: 175 B

View File

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

After

Width:  |  Height:  |  Size: 157 B

View File

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

After

Width:  |  Height:  |  Size: 371 B

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,40 @@ import disconnectIcon from '../assets/icons/disconnect.svg';
import cameraIcon from '../assets/icons/camera.svg';
import screenIcon from '../assets/icons/screen.svg';
// Helper Component for coloring SVGs
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)';
const ICON_COLOR_ACTIVE = 'hsl(357.692 67.826% 54.902% / 1)';
const controlButtonStyle = {
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '6px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
};
function getUserColor(name) {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
}
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
function randomHex(length) {
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return bytesToHex(bytes);
}
const ColoredIcon = ({ src, color, size = '20px' }) => (
<div style={{
width: size,
@@ -46,18 +79,6 @@ const UserControlPanel = ({ username }) => {
const effectiveMute = isMuted || isDeafened;
const getUserColor = (name) => {
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
};
const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)';
const ICON_COLOR_ACTIVE = 'hsl(357.692 67.826% 54.902% / 1)';
return (
<div style={{
height: '64px',
@@ -94,7 +115,6 @@ const UserControlPanel = ({ username }) => {
}}>
{(username || '?').substring(0, 1).toUpperCase()}
</div>
{/* Status Indicator */}
<div style={{
position: 'absolute',
bottom: '-2px',
@@ -118,57 +138,19 @@ const UserControlPanel = ({ username }) => {
{/* Controls */}
<div style={{ display: 'flex' }}>
<button
onClick={toggleMute}
title={effectiveMute ? "Unmute" : "Mute"}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '6px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<button onClick={toggleMute} title={effectiveMute ? "Unmute" : "Mute"} style={controlButtonStyle}>
<ColoredIcon
src={effectiveMute ? mutedIcon : muteIcon}
color={effectiveMute ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
/>
</button>
<button
onClick={toggleDeafen}
title={isDeafened ? "Undeafen" : "Deafen"}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '6px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<button onClick={toggleDeafen} title={isDeafened ? "Undeafen" : "Deafen"} style={controlButtonStyle}>
<ColoredIcon
src={isDeafened ? defeanedIcon : defeanIcon}
color={isDeafened ? ICON_COLOR_ACTIVE : ICON_COLOR_DEFAULT}
/>
</button>
<button
title="User Settings"
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '6px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<button title="User Settings" style={controlButtonStyle}>
<ColoredIcon
src={settingsIcon}
color={ICON_COLOR_DEFAULT}
@@ -181,6 +163,92 @@ const UserControlPanel = ({ username }) => {
const headerButtonStyle = {
background: 'transparent',
border: 'none',
color: '#b9bbbe',
cursor: 'pointer',
fontSize: '18px',
padding: '0 4px'
};
const voicePanelButtonStyle = {
flex: 1,
alignItems: 'center',
minHeight: '32px',
background: 'hsl(240 calc(1*4%) 60.784% /0.0784313725490196)',
border: 'hsl(0 calc(1*0%) 100% /0.0784313725490196)',
borderColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.0392156862745098) 100%,hsl(0 0% 0% /0.0392156862745098) 0%)',
borderRadius: '8px',
cursor: 'pointer',
padding: '4px',
display: 'flex',
justifyContent: 'center'
};
const liveBadgeStyle = {
backgroundColor: '#ed4245',
borderRadius: '8px',
padding: '0 6px',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
textAlign: 'center',
height: '16px',
minHeight: '16px',
minWidth: '16px',
color: 'hsl(0 calc(1*0%) 100% /1)',
fontSize: '12px',
fontWeight: '700',
letterSpacing: '.02em',
lineHeight: '1.3333333333333333',
textTransform: 'uppercase',
display: 'flex',
alignItems: 'center',
marginRight: '4px'
};
const ACTIVE_SPEAKER_SHADOW = '0 0 0 0px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 2px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 3px color-mix(in oklab, hsl(240 calc(1*7.143%) 10.98% /1) 100%, #000 0%)';
const VOICE_ACTIVE_COLOR = "color-mix(in oklab, hsl(132.809 calc(1*34.902%) 50% /1) 100%, #000 0%)";
async function encryptKeyForUsers(convex, channelId, keyHex) {
const users = await convex.query(api.auth.getPublicKeys, {});
const batchKeys = [];
for (const u of users) {
if (!u.public_identity_key) continue;
try {
const payload = JSON.stringify({ [channelId]: keyHex });
const encryptedKeyHex = await window.cryptoAPI.publicEncrypt(u.public_identity_key, payload);
batchKeys.push({
channelId,
userId: u.id,
encryptedKeyBundle: encryptedKeyHex,
keyVersion: 1
});
} catch (e) {
console.error("Failed to encrypt for user", u.id, e);
}
}
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
}
function getScreenCaptureConstraints(selection) {
if (selection.type === 'device') {
return { video: { deviceId: { exact: selection.deviceId } }, audio: false };
}
return {
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: selection.sourceId
}
}
};
}
const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels }) => {
const [isCreating, setIsCreating] = useState(false);
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
@@ -191,14 +259,10 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
const convex = useConvex();
// Callbacks for Modal - Convex is reactive, no need to manually refresh
const onRenameChannel = (id, newName) => {
// Convex reactive queries auto-update
};
const onRenameChannel = () => {};
const onDeleteChannel = (id) => {
if (activeChannel === id) onSelectChannel(null);
// Convex reactive queries auto-update
};
const { connectToVoice, activeChannelId: voiceChannelId, connectionState, disconnectVoice, activeChannelName: voiceChannelName, voiceStates, room, activeSpeakers, setScreenSharing } = useVoice();
@@ -211,13 +275,13 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
const handleSubmitCreate = async (e) => {
if (e) e.preventDefault();
if (!newChannelName.trim()) {
setIsCreating(false);
return;
}
const name = newChannelName.trim();
const type = newChannelType;
const userId = localStorage.getItem('userId');
if (!userId) {
@@ -227,54 +291,19 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
}
try {
// 1. Create Channel via Convex
const { id: channelId } = await convex.mutation(api.channels.create, { name, type });
const { id: channelId } = await convex.mutation(api.channels.create, { name, type: newChannelType });
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 {
const payload = JSON.stringify({ [channelId]: keyHex });
const encryptedKeyHex = await window.cryptoAPI.publicEncrypt(u.public_identity_key, payload);
batchKeys.push({
channelId,
userId: u.id,
encryptedKeyBundle: encryptedKeyHex,
keyVersion: 1
});
} catch (e) {
console.error("Failed to encrypt for user", u.id, e);
}
}
// 4. Upload Keys Batch via Convex
await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys });
// No need to notify - Convex queries are reactive!
await encryptKeyForUsers(convex, channelId, keyHex);
} catch (keyErr) {
console.error("Critical: Failed to distribute keys", keyErr);
alert("Channel created but key distribution failed.");
}
// 5. Done - Convex reactive queries auto-update the channel list
setIsCreating(false);
} catch (err) {
console.error(err);
alert("Failed to create channel: " + err.message);
} finally {
setIsCreating(false);
}
};
@@ -286,43 +315,29 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
return;
}
const generalChannel = channels.find(c => c.name === 'general');
const targetChannelId = generalChannel ? generalChannel._id : activeChannel;
if (!targetChannelId) {
alert("No channel selected.");
return;
}
const targetKey = channelKeys?.[targetChannelId];
if (!targetKey) {
alert("Error: You don't have the key for this channel yet, so you can't invite others.");
return;
}
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('');
const inviteSecret = randomHex(32);
// 2. Prepare Key Bundle
const generalChannel = channels.find(c => c.name === 'general');
const targetChannelId = generalChannel ? generalChannel._id : activeChannel;
if (!targetChannelId) {
alert("No channel selected.");
return;
}
const targetKey = channelKeys ? channelKeys[targetChannelId] : null;
if (!targetKey) {
alert("Error: You don't have the key for this channel yet, so you can't invite others.");
return;
}
const payload = JSON.stringify({
[targetChannelId]: targetKey
});
// 3. Encrypt Payload
const payload = JSON.stringify({ [targetChannelId]: targetKey });
const encrypted = await window.cryptoAPI.encryptData(payload, inviteSecret);
const blob = JSON.stringify({ c: encrypted.content, t: encrypted.tag, iv: encrypted.iv });
const blob = JSON.stringify({
c: encrypted.content,
t: encrypted.tag,
iv: encrypted.iv
});
// 4. Create invite via Convex
await convex.mutation(api.invites.create, {
code: inviteCode,
encryptedPayload: blob,
@@ -330,85 +345,219 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
keyVersion: 1
});
// 5. Show Link
const link = `http://localhost:5173/#/register?code=${inviteCode}&key=${inviteSecret}`;
navigator.clipboard.writeText(link);
alert(`Invite Link Copied to Clipboard!\n\n${link}`);
} catch (e) {
console.error("Invite Error:", e);
alert("Failed to create invite. See console.");
}
};
// Screen Share Handler
const handleScreenShareSelect = async (selection) => {
if (!room) return;
try {
// Unpublish existing screen share if any
if (room.localParticipant.isScreenShareEnabled) {
await room.localParticipant.setScreenShareEnabled(false);
}
// Capture based on selection
let stream;
if (selection.type === 'device') {
stream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: selection.deviceId } },
audio: false
});
} else {
// Electron Screen/Window
stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: selection.sourceId
}
}
});
}
// Publish the video track
const stream = await navigator.mediaDevices.getUserMedia(getScreenCaptureConstraints(selection));
const track = stream.getVideoTracks()[0];
if (track) {
await room.localParticipant.publishTrack(track, {
name: 'screen_share',
source: Track.Source.ScreenShare
});
if (!track) return;
setScreenSharing(true);
await room.localParticipant.publishTrack(track, {
name: 'screen_share',
source: Track.Source.ScreenShare
});
track.onended = () => {
setScreenSharing(false);
room.localParticipant.setScreenShareEnabled(false).catch(console.error);
};
}
setScreenSharing(true);
track.onended = () => {
setScreenSharing(false);
room.localParticipant.setScreenShareEnabled(false).catch(console.error);
};
} catch (err) {
console.error("Error sharing screen:", err);
alert("Failed to share screen: " + err.message);
}
};
// Toggle Modal instead of direct toggle
const handleScreenShareClick = () => {
if (room?.localParticipant.isScreenShareEnabled) {
room.localParticipant.setScreenShareEnabled(false);
setScreenSharing(false);
} else {
setIsScreenShareModalOpen(true);
}
if (room?.localParticipant.isScreenShareEnabled) {
room.localParticipant.setScreenShareEnabled(false);
setScreenSharing(false);
} else {
setIsScreenShareModalOpen(true);
}
};
const handleChannelClick = (channel) => {
if (channel.type === 'voice' && voiceChannelId !== channel._id) {
connectToVoice(channel._id, channel.name, localStorage.getItem('userId'));
} else {
onSelectChannel(channel._id);
}
};
const renderDMView = () => (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<DMList
dmChannels={dmChannels}
activeDMChannel={activeDMChannel}
onSelectDM={(dm) => setActiveDMChannel(dm === 'friends' ? null : dm)}
onOpenDM={onOpenDM}
/>
</div>
);
const renderVoiceUsers = (channel) => {
const users = voiceStates[channel._id];
if (channel.type !== 'voice' || !users?.length) return null;
return (
<div style={{ marginLeft: 32, marginBottom: 8 }}>
{users.map(user => (
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
<div style={{
width: 24, height: 24, borderRadius: '50%',
backgroundColor: '#5865F2',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginRight: 8, fontSize: 10, color: 'white',
boxShadow: activeSpeakers.has(user.userId) ? ACTIVE_SPEAKER_SHADOW : 'none'
}}>
{user.username.substring(0, 1).toUpperCase()}
</div>
<span style={{ color: '#b9bbbe', fontSize: 14 }}>{user.username}</span>
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center' }}>
{user.isScreenSharing && <div style={liveBadgeStyle}>Live</div>}
{(user.isMuted || user.isDeafened) && (
<ColoredIcon src={mutedIcon} color="#b9bbbe" size="14px" />
)}
{user.isDeafened && (
<ColoredIcon src={defeanedIcon} color="#b9bbbe" size="14px" />
)}
</div>
</div>
))}
</div>
);
};
const renderServerView = () => (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
<div className="channel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span
style={{ cursor: 'pointer', maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis' }}
onClick={() => setIsServerSettingsOpen(true)}
title="Server Settings"
>
Secure Chat
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={handleStartCreate} title="Create New Channel" style={{ ...headerButtonStyle, marginRight: '4px' }}>
+
</button>
<button onClick={handleCreateInvite} title="Create Invite Link" style={headerButtonStyle}>
🔗
</button>
</div>
</div>
{isCreating && (
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
<form onSubmit={handleSubmitCreate}>
<div style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<label style={{ color: newChannelType==='text'?'white':'#b9bbbe', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('text')}>
Text
</label>
<label style={{ color: newChannelType==='voice'?'white':'#b9bbbe', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('voice')}>
Voice
</label>
</div>
<input
autoFocus
type="text"
placeholder={`new-${newChannelType}-channel`}
value={newChannelName}
onChange={(e) => setNewChannelName(e.target.value)}
style={{
width: '100%',
background: '#202225',
border: '1px solid #7289da',
borderRadius: '4px',
color: '#dcddde',
padding: '4px 8px',
fontSize: '14px',
outline: 'none'
}}
/>
</form>
<div style={{ fontSize: 10, color: '#b9bbbe', marginTop: 2, textAlign: 'right' }}>
Press Enter to Create {newChannelType === 'voice' && '(Voice)'}
</div>
</div>
)}
{channels.map(channel => (
<React.Fragment key={channel._id}>
<div
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
onClick={() => handleChannelClick(channel)}
style={{
position: 'relative',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingRight: '8px'
}}
>
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden', flex: 1 }}>
{channel.type === 'voice' ? (
<div style={{ marginRight: 6 }}>
<ColoredIcon
src={voiceIcon}
size="16px"
color={voiceStates[channel._id]?.length > 0 ? VOICE_ACTIVE_COLOR : "#8e9297"}
/>
</div>
) : (
<span style={{ color: '#8e9297', marginRight: '6px', flexShrink: 0 }}>#</span>
)}
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{channel.name}</span>
</div>
<button
className="channel-settings-icon"
onClick={(e) => {
e.stopPropagation();
setEditingChannel(channel);
}}
style={{
background: 'transparent',
border: 'none',
color: '#b9bbbe',
cursor: 'pointer',
fontSize: '12px',
padding: '2px 4px',
display: 'flex', alignItems: 'center',
opacity: '0.7',
transition: 'opacity 0.2s'
}}
>
</button>
</div>
{renderVoiceUsers(channel)}
</React.Fragment>
))}
</div>
);
return (
<div className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<div className="server-list">
{/* Home Button */}
<div
className={`server-icon ${view === 'me' ? 'active' : ''}`}
onClick={() => onViewChange('me')}
@@ -424,7 +573,6 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</svg>
</div>
{/* The Server Icon (Secure Chat) */}
<div
className={`server-icon ${view === 'server' ? 'active' : ''}`}
onClick={() => onViewChange('server')}
@@ -432,222 +580,9 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
>Sc</div>
</div>
{/* Channel List Area */}
{view === 'me' ? (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
<DMList
dmChannels={dmChannels}
activeDMChannel={activeDMChannel}
onSelectDM={(dm) => {
if (dm === 'friends') {
setActiveDMChannel(null);
} else {
setActiveDMChannel(dm);
}
}}
onOpenDM={onOpenDM}
/>
</div>
) : (
<div className="channel-list" style={{ flex: 1, overflowY: 'auto' }}>
<div className="channel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span
style={{ cursor: 'pointer', maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis' }}
onClick={() => setIsServerSettingsOpen(true)}
title="Server Settings"
>
Secure Chat
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={handleStartCreate}
title="Create New Channel"
style={{
background: 'transparent',
border: 'none',
color: '#b9bbbe',
cursor: 'pointer',
fontSize: '18px',
padding: '0 4px',
marginRight: '4px'
}}
>
+
</button>
<button
onClick={handleCreateInvite}
title="Create Invite Link"
style={{
background: 'transparent',
border: 'none',
color: '#b9bbbe',
cursor: 'pointer',
fontSize: '18px',
padding: '0 4px'
}}
>
🔗
</button>
</div>
</div>
{/* Inline Create Channel Input */}
{isCreating && (
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
<form onSubmit={handleSubmitCreate}>
<div style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<label style={{ color: newChannelType==='text'?'white':'#b9bbbe', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('text')}>
Text
</label>
<label style={{ color: newChannelType==='voice'?'white':'#b9bbbe', fontSize: 10, cursor: 'pointer' }} onClick={()=>setNewChannelType('voice')}>
Voice
</label>
</div>
<input
autoFocus
type="text"
placeholder={`new-${newChannelType}-channel`}
value={newChannelName}
onChange={(e) => setNewChannelName(e.target.value)}
style={{
width: '100%',
background: '#202225',
border: '1px solid #7289da',
borderRadius: '4px',
color: '#dcddde',
padding: '4px 8px',
fontSize: '14px',
outline: 'none'
}}
/>
</form>
<div style={{ fontSize: 10, color: '#b9bbbe', marginTop: 2, textAlign: 'right' }}>
Press Enter to Create {newChannelType === 'voice' && '(Voice)'}
</div>
</div>
)}
{channels.map(channel => (
<React.Fragment key={channel._id}>
<div
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
onClick={() => {
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={{
position: 'relative',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingRight: '8px'
}}
>
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden', flex: 1 }}>
{channel.type === 'voice' ? (
<div style={{ marginRight: 6 }}>
<ColoredIcon
src={voiceIcon}
size="16px"
color={voiceStates[channel._id]?.length > 0
? "color-mix(in oklab, hsl(132.809 calc(1*34.902%) 50% /1) 100%, #000 0%)"
: "#8e9297"
}
/>
</div>
) : (
<span style={{ color: '#8e9297', marginRight: '6px', flexShrink: 0 }}>#</span>
)}
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{channel.name}</span>
</div>
<button
className="channel-settings-icon"
onClick={(e) => {
e.stopPropagation();
setEditingChannel(channel);
}}
style={{
background: 'transparent',
border: 'none',
color: '#b9bbbe',
cursor: 'pointer',
fontSize: '12px',
padding: '2px 4px',
display: 'flex', alignItems: 'center',
opacity: '0.7',
transition: 'opacity 0.2s'
}}
>
</button>
</div>
{channel.type === 'voice' && voiceStates[channel._id] && voiceStates[channel._id].length > 0 && (
<div style={{ marginLeft: 32, marginBottom: 8 }}>
{voiceStates[channel._id].map(user => (
<div key={user.userId} style={{ display: 'flex', alignItems: 'center', marginBottom: 4 }}>
<div style={{
width: 24, height: 24, borderRadius: '50%',
backgroundColor: '#5865F2',
display: 'flex', alignItems: 'center', justifyContent: 'center',
marginRight: 8, fontSize: 10, color: 'white',
boxShadow: activeSpeakers.has(user.userId)
? '0 0 0 0px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 2px hsl(134.526 calc(1*41.485%) 44.902% /1), inset 0 0 0 3px color-mix(in oklab, hsl(240 calc(1*7.143%) 10.98% /1) 100%, #000 0%)'
: 'none'
}}>
{user.username.substring(0, 1).toUpperCase()}
</div>
<span style={{ color: '#b9bbbe', fontSize: 14 }}>{user.username}</span>
<div style={{ display: 'flex', marginLeft: 'auto', gap: 4, alignItems: 'center' }}>
{user.isScreenSharing && (
<div style={{
backgroundColor: '#ed4245',
borderRadius: '8px',
padding: '0 6px',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
textAlign: 'center',
height: '16px',
minHeight: '16px',
minWidth: '16px',
color: 'hsl(0 calc(1*0%) 100% /1)',
fontSize: '12px',
fontWeight: '700',
letterSpacing: '.02em',
lineHeight: '1.3333333333333333',
textTransform: 'uppercase',
display: 'flex',
alignItems: 'center',
marginRight: '4px'
}}>
Live
</div>
)}
{(user.isMuted || user.isDeafened) && (
<ColoredIcon src={mutedIcon} color="#b9bbbe" size="14px" />
)}
{user.isDeafened && (
<ColoredIcon src={defeanedIcon} color="#b9bbbe" size="14px" />
)}
</div>
</div>
))}
</div>
)}
</React.Fragment>
))}
</div>
)}
{view === 'me' ? renderDMView() : renderServerView()}
</div>
{/* Voice Connection Panel */}
{connectionState === 'connected' && (
<div style={{
backgroundColor: '#292b2f',
@@ -672,32 +607,18 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
</div>
<div style={{ color: '#dcddde', fontSize: 12, marginBottom: 8 }}>{voiceChannelName} / Secure Chat</div>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)}
title="Turn On Camera"
style={{
flex: 1, alignItems: 'center', minHeight: '32px', padding: "calc(var(--space-8) - 1px) calc(var(--space-16) - 1px)", background: 'hsl(240 calc(1*4%) 60.784% /0.0784313725490196)', border: 'hsl(0 calc(1*0%) 100% /0.0784313725490196)', borderColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.0392156862745098) 100%,hsl(0 0% 0% /0.0392156862745098) 0%)', borderRadius: '8px', cursor: 'pointer', padding: '4px', display: 'flex', justifyContent: 'center'
}}
>
<button onClick={() => room?.localParticipant.setCameraEnabled(!room.localParticipant.isCameraEnabled)} title="Turn On Camera" style={voicePanelButtonStyle}>
<ColoredIcon src={cameraIcon} color="#b9bbbe" size="20px" />
</button>
<button
onClick={handleScreenShareClick}
title="Share Screen"
style={{
flex: 1, alignItems: 'center', minHeight: '32px', padding: "calc(var(--space-8) - 1px) calc(var(--space-16) - 1px)", background: 'hsl(240 calc(1*4%) 60.784% /0.0784313725490196)', border: 'hsl(0 calc(1*0%) 100% /0.0784313725490196)', borderColor: 'color-mix(in oklab,hsl(240 calc(1*4%) 60.784% /0.0392156862745098) 100%,hsl(0 0% 0% /0.0392156862745098) 0%)', borderRadius: '8px', cursor: 'pointer', padding: '4px', display: 'flex', justifyContent: 'center'
}}
>
<button onClick={handleScreenShareClick} title="Share Screen" style={voicePanelButtonStyle}>
<ColoredIcon src={screenIcon} color="#b9bbbe" size="20px" />
</button>
</div>
</div>
)}
{/* User Control Panel at Bottom, Spanning Full Width */}
<UserControlPanel username={username} />
{/* Modals */}
{editingChannel && (
<ChannelSettingsModal
channel={editingChannel}

View File

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

View File

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

View File

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

View File

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

View File

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