feat: Add initial Discord clone application with Convex backend services and Electron React frontend components.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>discord</title>
|
||||
<script type="module" crossorigin src="./assets/index-B1qeTixj.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-DXKRzYO-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-D1fin5Al.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
3
Frontend/Electron/src/assets/icons/close.svg
Normal file
3
Frontend/Electron/src/assets/icons/close.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M11 1.576 6.583 6 11 10.424l-.576.576L6 6.583 1.576 11 1 10.424 5.417 6 1 1.576 1.576 1 6 5.417 10.424 1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 270 B |
4
Frontend/Electron/src/assets/icons/help.svg
Normal file
4
Frontend/Electron/src/assets/icons/help.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg class="icon__9293f" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" fill="transparent"/>
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M12 23a11 11 0 1 0 0-22 11 11 0 0 0 0 22m-.28-16c-.98 0-1.81.47-2.27 1.14A1 1 0 1 1 7.8 7.01 4.73 4.73 0 0 1 11.72 5c2.5 0 4.65 1.88 4.65 4.38 0 2.1-1.54 3.77-3.52 4.24l.14 1a1 1 0 0 1-1.98.27l-.28-2a1 1 0 0 1 .99-1.14c1.54 0 2.65-1.14 2.65-2.38 0-1.23-1.1-2.37-2.65-2.37M13 17.88a1.13 1.13 0 1 1-2.25 0 1.13 1.13 0 0 1 2.25 0" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 597 B |
3
Frontend/Electron/src/assets/icons/inbox.svg
Normal file
3
Frontend/Electron/src/assets/icons/inbox.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M5 2a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3V5a3 3 0 0 0-3-3zM4 5.5C4 4.67 4.67 4 5.5 4h13c.83 0 1.5.67 1.5 1.5v6c0 .83-.67 1.5-1.5 1.5h-2.65c-.5 0-.85.5-.85 1a3 3 0 1 1-6 0c0-.5-.35-1-.85-1H5.5A1.5 1.5 0 0 1 4 11.5z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 421 B |
3
Frontend/Electron/src/assets/icons/max.svg
Normal file
3
Frontend/Electron/src/assets/icons/max.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
|
||||
<path fill="none" stroke="currentColor" d="M1.5 1.5h9v9h-9z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 175 B |
3
Frontend/Electron/src/assets/icons/min.svg
Normal file
3
Frontend/Electron/src/assets/icons/min.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
|
||||
<path fill="currentColor" d="M1 6h10v1H1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 157 B |
3
Frontend/Electron/src/assets/icons/update.svg
Normal file
3
Frontend/Electron/src/assets/icons/update.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg class="icon__9293f" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12 2a1 1 0 0 1 1 1v10.59l3.3-3.3a1 1 0 1 1 1.4 1.42l-5 5a1 1 0 0 1-1.4 0l-5-5a1 1 0 1 1 1.4-1.42l3.3 3.3V3a1 1 0 0 1 1-1M3 20a1 1 0 1 0 0 2h18a1 1 0 1 0 0-2z" class="updateIconForeground__49676"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 371 B |
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useConvex } from 'convex/react';
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { useQuery, usePaginatedQuery, useMutation, useConvex } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
@@ -27,14 +27,71 @@ const heartIcon = getEmojiUrl('symbols', 'heart');
|
||||
const thumbsupIcon = getEmojiUrl('people', 'thumbsup');
|
||||
import GifPicker from './GifPicker';
|
||||
|
||||
// Cache for link metadata to prevent pop-in
|
||||
const metadataCache = new Map();
|
||||
const attachmentCache = new Map();
|
||||
|
||||
const ICON_COLOR_DEFAULT = 'color-mix(in oklab, hsl(240 4.294% 68.039% / 1) 100%, #000 0%)';
|
||||
const ICON_COLOR_DANGER = 'color-mix(in oklab, hsl(1.353 calc(1*82.609%) 68.431% /1) 100%, #000 0%)';
|
||||
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
|
||||
const fromHexString = (hexString) =>
|
||||
new Uint8Array(hexString.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)));
|
||||
|
||||
const toHexString = (bytes) =>
|
||||
bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
|
||||
|
||||
const getUserColor = (name) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); }
|
||||
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
|
||||
};
|
||||
|
||||
const getReactionIcon = (name) => {
|
||||
switch (name) {
|
||||
case 'thumbsup': return thumbsupIcon;
|
||||
case 'heart': return heartIcon;
|
||||
case 'fire': return fireIcon;
|
||||
default: return heartIcon;
|
||||
}
|
||||
};
|
||||
|
||||
const extractUrls = (text) => {
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||
return text.match(urlRegex) || [];
|
||||
};
|
||||
|
||||
const getYouTubeId = (link) => {
|
||||
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||
const match = link.match(regExp);
|
||||
return (match && match[2].length === 11) ? match[2] : null;
|
||||
};
|
||||
|
||||
const formatMentions = (text) => {
|
||||
if (!text) return '';
|
||||
return text.replace(/@(\w+)/g, '[@$1](mention://$1)');
|
||||
};
|
||||
|
||||
const formatEmojis = (text) => {
|
||||
if (!text) return '';
|
||||
return text.replace(/:([a-zA-Z0-9_]+):/g, (match, name) => {
|
||||
const emoji = AllEmojis.find(e => e.name === name);
|
||||
return emoji ? `` : match;
|
||||
});
|
||||
};
|
||||
|
||||
const isNewDay = (current, previous) => {
|
||||
if (!previous) return true;
|
||||
return current.getDate() !== previous.getDate()
|
||||
|| current.getMonth() !== previous.getMonth()
|
||||
|| current.getFullYear() !== previous.getFullYear();
|
||||
};
|
||||
|
||||
// Extracted LinkPreview to prevent re-renders on ChatArea updates
|
||||
const LinkPreview = ({ url }) => {
|
||||
const [metadata, setMetadata] = useState(metadataCache.get(url) || null);
|
||||
const [loading, setLoading] = useState(!metadataCache.has(url));
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
const videoRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (metadataCache.has(url)) {
|
||||
@@ -61,15 +118,6 @@ const LinkPreview = ({ url }) => {
|
||||
return () => { isMounted = false; };
|
||||
}, [url]);
|
||||
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
const videoRef = useRef(null);
|
||||
|
||||
const getYouTubeId = (link) => {
|
||||
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
|
||||
const match = link.match(regExp);
|
||||
return (match && match[2].length === 11) ? match[2] : null;
|
||||
};
|
||||
|
||||
const videoId = getYouTubeId(url);
|
||||
const isYouTube = !!videoId;
|
||||
|
||||
@@ -78,9 +126,7 @@ const LinkPreview = ({ url }) => {
|
||||
if (metadata.video && !isYouTube) {
|
||||
const handlePlayClick = () => {
|
||||
setShowControls(true);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play();
|
||||
}
|
||||
if (videoRef.current) videoRef.current.play();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -140,14 +186,6 @@ const LinkPreview = ({ url }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const fromHexString = (hexString) =>
|
||||
new Uint8Array(hexString.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)));
|
||||
|
||||
const toHexString = (bytes) =>
|
||||
bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
|
||||
|
||||
const attachmentCache = new Map();
|
||||
|
||||
const Attachment = ({ metadata, onLoad, onImageClick }) => {
|
||||
const [url, setUrl] = useState(attachmentCache.get(metadata.url) || null);
|
||||
const [loading, setLoading] = useState(!attachmentCache.has(metadata.url));
|
||||
@@ -243,24 +281,30 @@ const PendingFilePreview = ({ file, onRemove }) => {
|
||||
<ColoredIcon src={icon} color="#fff" size="16px" />
|
||||
</div>
|
||||
);
|
||||
|
||||
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 ``;
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -273,6 +273,28 @@ body {
|
||||
background-color: rgba(2, 2, 2, 0.06);
|
||||
}
|
||||
|
||||
.message-grouped {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.grouped-timestamp-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.grouped-timestamp {
|
||||
display: none;
|
||||
font-size: 0.65rem;
|
||||
color: #72767d;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-grouped:hover .grouped-timestamp {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message-avatar-wrapper {
|
||||
width: 40px;
|
||||
margin-right: 16px;
|
||||
@@ -631,6 +653,54 @@ body {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: #5865f2;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Channel beginning indicator */
|
||||
.channel-beginning {
|
||||
padding: 16px 16px 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.channel-beginning-icon {
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
border-radius: 50%;
|
||||
background-color: #41434a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.channel-beginning-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.channel-beginning-subtitle {
|
||||
font-size: 15px;
|
||||
color: #949ba4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Utility to hide scrollbar but allow scrolling */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
|
||||
@@ -8,33 +8,27 @@ import { useVoice } from '../contexts/VoiceContext';
|
||||
import FriendsView from '../components/FriendsView';
|
||||
|
||||
const Chat = () => {
|
||||
const [view, setView] = useState('server'); // 'server' | 'me'
|
||||
const [view, setView] = useState('server');
|
||||
const [activeChannel, setActiveChannel] = useState(null);
|
||||
const [username, setUsername] = useState('');
|
||||
const [userId, setUserId] = useState(null);
|
||||
const [channelKeys, setChannelKeys] = useState({}); // { channelId: key_hex }
|
||||
|
||||
// DM state
|
||||
const [activeDMChannel, setActiveDMChannel] = useState(null); // { channel_id, other_username }
|
||||
const [channelKeys, setChannelKeys] = useState({});
|
||||
const [activeDMChannel, setActiveDMChannel] = useState(null);
|
||||
|
||||
const convex = useConvex();
|
||||
|
||||
// Reactive channel list from Convex (auto-updates!)
|
||||
const channels = useQuery(api.channels.list) || [];
|
||||
|
||||
// Reactive channel keys from Convex
|
||||
const rawChannelKeys = useQuery(
|
||||
api.channelKeys.getKeysForUser,
|
||||
userId ? { userId } : "skip"
|
||||
);
|
||||
|
||||
// Reactive DM channels from Convex
|
||||
const dmChannels = useQuery(
|
||||
api.dms.listDMs,
|
||||
userId ? { userId } : "skip"
|
||||
) || [];
|
||||
|
||||
// Initialize user from localStorage
|
||||
useEffect(() => {
|
||||
const storedUsername = localStorage.getItem('username');
|
||||
const storedUserId = localStorage.getItem('userId');
|
||||
@@ -42,58 +36,50 @@ const Chat = () => {
|
||||
if (storedUserId) setUserId(storedUserId);
|
||||
}, []);
|
||||
|
||||
// Decrypt channel keys when raw keys change
|
||||
useEffect(() => {
|
||||
if (!rawChannelKeys || rawChannelKeys.length === 0) return;
|
||||
const privateKey = sessionStorage.getItem('privateKey');
|
||||
if (!privateKey) return;
|
||||
|
||||
const decryptKeys = async () => {
|
||||
async function decryptKeys() {
|
||||
const keys = {};
|
||||
for (const item of rawChannelKeys) {
|
||||
try {
|
||||
const bundleJson = await window.cryptoAPI.privateDecrypt(privateKey, item.encrypted_key_bundle);
|
||||
const bundle = JSON.parse(bundleJson);
|
||||
Object.assign(keys, bundle);
|
||||
Object.assign(keys, JSON.parse(bundleJson));
|
||||
} catch (e) {
|
||||
console.error(`Failed to decrypt keys for channel ${item.channel_id}`, e);
|
||||
}
|
||||
}
|
||||
setChannelKeys(keys);
|
||||
};
|
||||
}
|
||||
|
||||
decryptKeys();
|
||||
}, [rawChannelKeys]);
|
||||
|
||||
// Auto-select first text channel when channels load
|
||||
useEffect(() => {
|
||||
if (!activeChannel && channels.length > 0) {
|
||||
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}
|
||||
|
||||
@@ -3,6 +3,11 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useConvex } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
|
||||
async function decryptEncryptedField(encryptedJson, keyHex) {
|
||||
const obj = JSON.parse(encryptedJson);
|
||||
return window.cryptoAPI.decryptData(obj.content, keyHex, obj.iv, obj.tag);
|
||||
}
|
||||
|
||||
const Login = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -19,15 +24,12 @@ const Login = () => {
|
||||
try {
|
||||
console.log('Starting login for:', username);
|
||||
|
||||
// 1. Get Salt (via Convex query)
|
||||
const { salt } = await convex.query(api.auth.getSalt, { username });
|
||||
console.log('Got salt');
|
||||
|
||||
// 2. Derive Keys (DEK, DAK)
|
||||
const { dek, dak } = await window.cryptoAPI.deriveAuthKeys(password, salt);
|
||||
console.log('Derived keys');
|
||||
|
||||
// 3. Verify with Convex
|
||||
const verifyData = await convex.mutation(api.auth.verifyUser, { username, dak });
|
||||
|
||||
if (verifyData.error) {
|
||||
@@ -43,39 +45,15 @@ const Login = () => {
|
||||
console.error('MISSING USERID IN VERIFY RESPONSE!', verifyData);
|
||||
}
|
||||
|
||||
// 4. Decrypt Master Key (using DEK)
|
||||
console.log('Decrypting Master Key...');
|
||||
const encryptedMKObj = JSON.parse(verifyData.encryptedMK);
|
||||
const mkHex = await window.cryptoAPI.decryptData(
|
||||
encryptedMKObj.content,
|
||||
dek,
|
||||
encryptedMKObj.iv,
|
||||
encryptedMKObj.tag
|
||||
);
|
||||
const mkHex = await decryptEncryptedField(verifyData.encryptedMK, dek);
|
||||
|
||||
// 5. Decrypt Private Keys (using MK)
|
||||
console.log('Decrypting Private Keys...');
|
||||
const encryptedPrivateKeysObj = JSON.parse(verifyData.encryptedPrivateKeys);
|
||||
|
||||
// Decrypt Ed25519 Signing Key
|
||||
const edPrivObj = encryptedPrivateKeysObj.ed;
|
||||
const signingKey = await window.cryptoAPI.decryptData(
|
||||
edPrivObj.content,
|
||||
mkHex,
|
||||
edPrivObj.iv,
|
||||
edPrivObj.tag
|
||||
);
|
||||
const signingKey = await decryptEncryptedField(JSON.stringify(encryptedPrivateKeysObj.ed), mkHex);
|
||||
const rsaPriv = await decryptEncryptedField(JSON.stringify(encryptedPrivateKeysObj.rsa), mkHex);
|
||||
|
||||
// Decrypt RSA Private Key (Identity Key)
|
||||
const rsaPrivObj = encryptedPrivateKeysObj.rsa;
|
||||
const rsaPriv = await window.cryptoAPI.decryptData(
|
||||
rsaPrivObj.content,
|
||||
mkHex,
|
||||
rsaPrivObj.iv,
|
||||
rsaPrivObj.tag
|
||||
);
|
||||
|
||||
// Store Keys in Session (Memory-like) storage
|
||||
sessionStorage.setItem('signingKey', signingKey);
|
||||
sessionStorage.setItem('privateKey', rsaPriv);
|
||||
console.log('Keys decrypted and stored in session.');
|
||||
@@ -85,7 +63,6 @@ const Login = () => {
|
||||
localStorage.setItem('publicKey', verifyData.publicKey);
|
||||
}
|
||||
|
||||
// Verify immediate read back
|
||||
console.log('Immediate localStorage read check:', localStorage.getItem('userId'));
|
||||
|
||||
navigate('/chat');
|
||||
|
||||
@@ -3,12 +3,19 @@ import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useConvex } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
|
||||
function parseInviteParams(input) {
|
||||
const codeMatch = input.match(/[?&]code=([^&]+)/);
|
||||
const keyMatch = input.match(/[?&]key=([^&]+)/);
|
||||
if (codeMatch && keyMatch) return { code: codeMatch[1], secret: keyMatch[1] };
|
||||
return null;
|
||||
}
|
||||
|
||||
const Register = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inviteKeys, setInviteKeys] = useState(null); // { channelId: keyHex }
|
||||
const [inviteKeys, setInviteKeys] = useState(null);
|
||||
const [inviteLinkInput, setInviteLinkInput] = useState('');
|
||||
const [activeInviteCode, setActiveInviteCode] = useState(null);
|
||||
|
||||
@@ -16,7 +23,6 @@ const Register = () => {
|
||||
const location = useLocation();
|
||||
const convex = useConvex();
|
||||
|
||||
// Helper to process code/key
|
||||
const processInvite = async (code, secret) => {
|
||||
if (!window.cryptoAPI) {
|
||||
setError("Critical Error: Secure Crypto API missing. Run in Electron.");
|
||||
@@ -24,16 +30,13 @@ const Register = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch Invite via Convex
|
||||
const result = await convex.query(api.invites.use, { code });
|
||||
if (result.error) throw new Error(result.error);
|
||||
const { encryptedPayload } = result;
|
||||
|
||||
// Decrypt Payload
|
||||
const blob = JSON.parse(encryptedPayload);
|
||||
const blob = JSON.parse(result.encryptedPayload);
|
||||
const decrypted = await window.cryptoAPI.decryptData(blob.c, secret, blob.iv, blob.t);
|
||||
|
||||
const keys = JSON.parse(decrypted);
|
||||
|
||||
console.log('Invite keys decrypted successfully:', Object.keys(keys).length);
|
||||
setInviteKeys(keys);
|
||||
setActiveInviteCode(code);
|
||||
@@ -44,7 +47,6 @@ const Register = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Invite Link parsing from URL
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const code = params.get('code');
|
||||
@@ -57,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 ? (
|
||||
|
||||
Reference in New Issue
Block a user