feat: Add initial Electron app structure with real-time chat, user authentication, and encrypted messaging.

This commit is contained in:
bryan
2025-12-30 15:09:17 -06:00
parent f0e8d9400a
commit b26a1d0b4b
9 changed files with 682 additions and 201 deletions

View File

@@ -10,20 +10,26 @@ const ChatArea = ({ channelId, username }) => {
const [input, setInput] = useState('');
const [socket, setSocket] = useState(null);
const messagesEndRef = useRef(null);
const textareaRef = useRef(null);
// Mock Key for demo (In real app, derive from Channel Key Bundle)
const DEMO_CHANNEL_KEY = '000102030405060708090a0b0c0d0e0f';
// Mock Key for demo (32 bytes hex = 64 chars)
const DEMO_CHANNEL_KEY = '000102030405060708090a0b0c0d0e0f000102030405060708090a0b0c0d0e0f';
// Helper to get consistent color for user
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];
};
// Helper to decrypt message
const decryptMessage = async (msg) => {
try {
// Check if ciphertext has appended tag
// Tag is 16 bytes = 32 hex chars
const TAG_LENGTH = 32;
if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) {
// Try decrypting without tag if it was legacy (though we just started)
// Or maybe it's just raw text if not encrypted? No, we always encrypt.
console.warn('Message missing tag, trying raw decrypt or fail');
return '[Invalid Encrypted Message]';
}
@@ -38,6 +44,85 @@ const ChatArea = ({ channelId, username }) => {
}
};
// Helper to extract URLs
const extractUrls = (text) => {
const urlRegex = /(https?:\/\/[^\s]+)/g;
return text.match(urlRegex) || [];
};
const LinkPreview = ({ url }) => {
const [metadata, setMetadata] = useState(null);
const [loading, setLoading] = useState(true);
const [playing, setPlaying] = useState(false);
useEffect(() => {
let isMounted = true;
const fetchMeta = async () => {
try {
const data = await window.cryptoAPI.fetchMetadata(url);
if (isMounted) {
setMetadata(data);
setLoading(false);
}
} catch (err) {
console.error("Failed to fetch metadata", err);
if (isMounted) setLoading(false);
}
};
fetchMeta();
return () => { isMounted = false; };
}, [url]);
// Helper to extract video ID
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;
if (loading || !metadata || (!metadata.title && !metadata.image)) return null;
return (
<div className={`link-preview ${isYouTube ? 'youtube-preview' : ''}`} style={{ borderLeftColor: metadata.themeColor || '#202225' }}>
<div className="preview-content">
{metadata.siteName && <div className="preview-site-name">{metadata.siteName}</div>}
{metadata.title && (
<a href={url} onClick={(e) => { e.preventDefault(); window.cryptoAPI.openExternal(url); }} className="preview-title">
{metadata.title}
</a>
)}
{metadata.description && <div className="preview-description">{metadata.description}</div>}
{isYouTube && playing && (
<div className="youtube-video-wrapper">
<iframe
src={`https://www.youtube.com/embed/${videoId}?autoplay=1`}
title={metadata.title || "YouTube video player"}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
)}
</div>
{metadata.image && (!isYouTube || !playing) && (
<div
className="preview-image-container"
onClick={() => isYouTube && setPlaying(true)}
style={isYouTube ? { cursor: 'pointer' } : {}}
>
<img src={metadata.image} alt="Preview" className="preview-image" />
{isYouTube && <div className="play-icon"></div>}
</div>
)}
</div>
);
};
useEffect(() => {
const newSocket = io('http://localhost:3000');
setSocket(newSocket);
@@ -64,32 +149,37 @@ const ChatArea = ({ channelId, username }) => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`;
}
}, [input]);
const handleSend = async (e) => {
e.preventDefault();
if (!input.trim()) return;
try {
// Encrypt message
const { content: encryptedContent, iv, tag } = await window.cryptoAPI.encryptData(input, DEMO_CHANNEL_KEY);
// Append tag to ciphertext for storage
const ciphertext = encryptedContent + tag;
// Sign message (placeholder)
const signature = 'placeholder_signature';
const senderId = localStorage.getItem('userId');
if (!senderId) {
console.error('No userId found in localStorage');
return;
}
const messageData = {
channelId,
senderId: '8b105be1-981e-4200-bb07-68d0714870c2', // Placeholder default, gets overwritten below
senderId,
ciphertext,
nonce: iv,
signature,
keyVersion: 1
};
const storedUserId = localStorage.getItem('userId');
if (storedUserId) messageData.senderId = storedUserId;
socket.emit('send_message', messageData);
setInput('');
} catch (err) {
@@ -107,51 +197,114 @@ const ChatArea = ({ channelId, username }) => {
return (
<div className="chat-area">
<div className="messages-list">
{messages.map((msg, idx) => (
<div key={idx} className="message-item">
<div className="message-header">
<span className="username">{msg.username || 'Unknown'}</span>
<span className="timestamp">{new Date(msg.created_at).toLocaleTimeString()}</span>
{messages.map((msg, idx) => {
const urls = extractUrls(msg.content);
return (
<div key={idx} className="message-item">
<div className="message-avatar-wrapper">
<div
className="message-avatar"
style={{ backgroundColor: getUserColor(msg.username || 'Unknown') }}
>
{(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>
<span className="timestamp">
{new Date(msg.created_at).toLocaleDateString()} at {new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="message-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ node, ...props }) => (
<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} />,
}}
>
{msg.content}
</ReactMarkdown>
{urls.map((url, i) => (
<LinkPreview key={i} url={url} />
))}
</div>
</div>
</div>
<div className="message-content">
<ReactMarkdown
pluginPlugins={[remarkGfm]}
components={{
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>
)
}
}}
>
{msg.content}
</ReactMarkdown>
</div>
</div>
))}
);
})}
<div ref={messagesEndRef} />
</div>
<form className="chat-input-form" onSubmit={handleSend}>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Message #${channelId}`}
rows={1}
style={{ resize: 'none' }} // Disable manual resize
/>
<div className="chat-input-wrapper">
<button type="button" className="chat-input-file-btn">
<svg aria-hidden="true" role="img" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2Z"></path>
</svg>
</button>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Message #${channelId}`}
rows={1}
style={{ height: '44px' }}
/>
<div className="chat-input-icons">
<button type="button" className="chat-input-icon-btn">
<span style={{ fontWeight: 700, fontSize: '12px', border: '2px solid currentColor', borderRadius: '4px', padding: '0 2px' }}>GIF</span>
</button>
<button type="button" className="chat-input-icon-btn">
<svg aria-hidden="true" role="img" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" fillRule="evenodd" clipRule="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-3H5Zm11.8 13.9a5.5 5.5 0 0 1-9.14 0 1 1 0 0 1 .27-1.4 1 1 0 0 1 1.4.27 3.5 3.5 0 0 0 5.8 0 1 1 0 0 1 1.67 1.13Z"></path>
</svg>
</button>
<button type="button" className="chat-input-icon-btn">
<svg aria-hidden="true" role="img" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8Zm-5.5-2.5a5.5 5.5 0 0 1 9 0 .5.5 0 0 1-.8.6 4.5 4.5 0 0 0-7.4 0 .5.5 0 0 1-.8-.6ZM8.5 10a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Zm5.5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Z"></path>
</svg>
</button>
</div>
</div>
</form>
</div>
);

View File

@@ -31,7 +31,6 @@ body {
justify-content: center;
height: 100vh;
background-image: url('https://discord.com/assets/f9e794909795f472.svg');
/* Placeholder background */
background-size: cover;
background-position: center;
}
@@ -122,6 +121,7 @@ body {
/* Sidebar */
.sidebar {
width: 300px;
min-width: 300px;
background-color: var(--bg-secondary);
display: flex;
flex-direction: row;
@@ -201,28 +201,71 @@ body {
background-color: var(--bg-primary);
display: flex;
flex-direction: column;
position: relative;
}
.messages-list {
flex: 1;
overflow-y: auto;
padding: 20px;
padding: 0 0 20px 0;
display: flex;
flex-direction: column;
}
.messages-list::-webkit-scrollbar {
width: 8px;
background-color: #2b2d31;
}
.messages-list::-webkit-scrollbar-thumb {
background-color: #1a1b1e;
border-radius: 4px;
}
.message-item {
margin-bottom: 20px;
display: flex;
padding: 2px 16px;
margin-top: 17px;
}
.message-item:hover {
background-color: rgba(2, 2, 2, 0.06);
}
.message-avatar-wrapper {
width: 40px;
margin-right: 16px;
margin-top: 2px;
}
.message-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
color: white;
font-size: 18px;
user-select: none;
}
.message-body {
flex: 1;
min-width: 0;
}
.message-header {
display: flex;
align-items: baseline;
margin-bottom: 4px;
align-items: center;
margin-bottom: 2px;
}
.username {
color: var(--header-primary);
font-size: 16px;
font-weight: 500;
margin-right: 8px;
margin-right: 0.25rem;
cursor: pointer;
}
@@ -232,54 +275,267 @@ body {
.timestamp {
color: var(--text-muted);
font-size: 12px;
font-size: 0.75rem;
margin-left: 0.25rem;
font-weight: 400;
}
.message-content {
color: var(--text-normal);
font-size: 1rem;
line-height: 1.375rem;
white-space: pre-wrap;
word-wrap: break-word;
}
.chat-input-form {
padding: 0 16px 24px;
/* Markdown Styles Tweaks */
.message-content strong {
font-weight: 700;
}
.chat-input-form textarea {
width: 100%;
padding: 11px 16px;
background-color: #40444b;
border: none;
border-radius: 8px;
color: var(--text-normal);
font-size: 16px;
font-family: inherit;
height: auto;
min-height: 44px;
.message-content h1,
.message-content h2,
.message-content h3 {
border-bottom: none;
font-weight: 700;
color: var(--header-primary);
}
.chat-input-form textarea:focus {
outline: none;
.message-content ul,
.message-content ol {
list-style-type: disc;
}
/* Markdown Styles */
.message-content p {
margin: 0;
.message-content a {
color: #00b0f4;
text-decoration: none;
}
.message-content a:hover {
text-decoration: underline;
}
.message-content blockquote {
border-left: 4px solid #4f545c;
margin: 4px 0 4px 0;
padding: 0 8px 0 12px;
color: #b9bbbe;
background-color: transparent;
}
.message-content code {
background-color: #2f3136;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
font-family: Consolas, 'Courier New', monospace;
font-size: 0.875rem;
}
.message-content pre {
margin: 6px 0;
background-color: #2f3136 !important;
border-radius: 4px;
border: 1px solid #202225;
padding: 8px !important;
margin: 8px 0 !important;
max-width: 100%;
overflow-x: auto;
}
.message-content blockquote {
border-left: 4px solid var(--interactive-normal);
margin: 0;
padding-left: 10px;
.chat-input-form {
padding: 0 16px 24px;
margin-top: 8px;
background-color: var(--bg-primary);
}
.chat-input-wrapper {
background-color: #40444b;
border-radius: 8px;
display: flex;
align-items: center;
padding: 0 16px;
min-height: 44px;
}
.chat-input-file-btn {
background: none;
border: none;
color: #b9bbbe;
cursor: pointer;
padding: 0 16px 0 0;
display: flex;
align-items: center;
height: 24px;
align-self: center;
}
.chat-input-file-btn:hover {
color: var(--text-normal);
}
.chat-input-form textarea {
flex: 1;
padding: 11px 0;
background-color: transparent;
border: none;
color: var(--text-normal);
font-size: 16px;
font-family: inherit;
height: 44px;
resize: none;
overflow: hidden;
line-height: 1.375rem;
box-sizing: border-box;
}
.chat-input-form textarea:focus {
outline: none;
}
.chat-input-form textarea::placeholder {
color: #72767d;
}
.chat-input-icons {
display: flex;
align-items: center;
padding: 0 0 0 10px;
align-self: center;
height: 100%;
}
.chat-input-icon-btn {
background: none;
border: none;
color: #b9bbbe;
cursor: pointer;
padding: 4px;
margin-left: 4px;
display: flex;
align-items: center;
}
.chat-input-icon-btn:hover {
color: var(--text-normal);
}
/* Link Previews */
.link-preview {
display: flex;
background-color: #2f3136;
border-left: 4px solid #202225;
border-radius: 4px;
padding: 10px;
margin-top: 8px;
max-width: 520px;
gap: 16px;
}
.preview-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
}
.preview-site-name {
font-size: 12px;
color: #b9bbbe;
margin-bottom: 4px;
}
.preview-title {
font-size: 16px;
font-weight: 600;
color: #00b0f4;
text-decoration: none;
margin-bottom: 4px;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-title:hover {
text-decoration: underline;
}
.preview-description {
font-size: 14px;
color: #dcddde;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.preview-image-container {
flex-shrink: 0;
position: relative;
}
.preview-image {
max-width: 150px;
max-height: 150px;
border-radius: 4px;
object-fit: cover;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.youtube-preview {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.youtube-preview .preview-image-container {
width: 100%;
max-width: 400px;
position: relative;
}
.youtube-preview .preview-image {
width: 100%;
max-width: 100%;
max-height: none;
aspect-ratio: 16 / 9;
}
.youtube-video-wrapper {
margin-top: 8px;
position: relative;
width: 100%;
max-width: 560px;
/* Standard YouTube embed width or max for chat */
padding-bottom: 56.25%;
/* 16:9 Aspect Ratio */
height: 0;
overflow: hidden;
border-radius: 4px;
background-color: black;
}
.youtube-video-wrapper iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}

View File

@@ -42,11 +42,20 @@ const Login = () => {
throw new Error(verifyData.error || 'Login failed');
}
console.log('Login verified');
console.log('Login verified. Response data:', verifyData);
if (verifyData.userId) {
console.log('Saving userId to localStorage:', verifyData.userId);
localStorage.setItem('userId', verifyData.userId);
} else {
console.error('MISSING USERID IN VERIFY RESPONSE!', verifyData);
}
localStorage.setItem('username', username);
// Verify immediate read back
console.log('Immediate localStorage read check:', localStorage.getItem('userId'));
navigate('/chat');
} catch (err) {
console.error('Login error:', err);

View File

@@ -18,7 +18,7 @@ const Register = () => {
// 1. Generate Salt and Master Key (MK)
const salt = await window.cryptoAPI.randomBytes(16);
const mk = await window.cryptoAPI.randomBytes(16); // 128-bit MK
const mk = await window.cryptoAPI.randomBytes(32); // 256-bit MK for AES-256
console.log('Generated Salt and MK');