feat: Initialize Electron application with core backend, frontend chat/login UI, and IPC for crypto and link previews.
This commit is contained in:
@@ -38,7 +38,7 @@ io.on('connection', (socket) => {
|
|||||||
// Load recent messages
|
// Load recent messages
|
||||||
try {
|
try {
|
||||||
const result = await db.query(
|
const result = await db.query(
|
||||||
`SELECT m.*, u.username
|
`SELECT m.*, u.username, u.public_signing_key
|
||||||
FROM messages m
|
FROM messages m
|
||||||
JOIN users u ON m.sender_id = u.id
|
JOIN users u ON m.sender_id = u.id
|
||||||
WHERE m.channel_id = $1
|
WHERE m.channel_id = $1
|
||||||
@@ -69,10 +69,11 @@ io.on('connection', (socket) => {
|
|||||||
...data
|
...data
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get username for display
|
// Get username and signing key for display/verification
|
||||||
const userRes = await db.query('SELECT username FROM users WHERE id = $1', [senderId]);
|
const userRes = await db.query('SELECT username, public_signing_key FROM users WHERE id = $1', [senderId]);
|
||||||
if (userRes.rows.length > 0) {
|
if (userRes.rows.length > 0) {
|
||||||
message.username = userRes.rows[0].username;
|
message.username = userRes.rows[0].username;
|
||||||
|
message.public_signing_key = userRes.rows[0].public_signing_key;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast to channel
|
// Broadcast to channel
|
||||||
|
|||||||
@@ -110,6 +110,15 @@ app.whenReady().then(() => {
|
|||||||
return crypto.createHash('sha256').update(data).digest('hex');
|
return crypto.createHash('sha256').update(data).digest('hex');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('sign-message', (event, privateKeyPem, message) => {
|
||||||
|
// message should be a string or buffer
|
||||||
|
return crypto.sign(null, Buffer.from(message), privateKeyPem).toString('hex');
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('verify-signature', (event, publicKeyPem, message, signature) => {
|
||||||
|
return crypto.verify(null, Buffer.from(message), publicKeyPem, Buffer.from(signature, 'hex'));
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('derive-auth-keys', (event, password, salt) => {
|
ipcMain.handle('derive-auth-keys', (event, password, salt) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
|
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ contextBridge.exposeInMainWorld('cryptoAPI', {
|
|||||||
generateKeys: () => ipcRenderer.invoke('generate-keys'),
|
generateKeys: () => ipcRenderer.invoke('generate-keys'),
|
||||||
randomBytes: (size) => ipcRenderer.invoke('random-bytes', size),
|
randomBytes: (size) => ipcRenderer.invoke('random-bytes', size),
|
||||||
sha256: (data) => ipcRenderer.invoke('sha256', data),
|
sha256: (data) => ipcRenderer.invoke('sha256', data),
|
||||||
|
signMessage: (privateKey, message) => ipcRenderer.invoke('sign-message', privateKey, message),
|
||||||
|
verifySignature: (publicKey, message, signature) => ipcRenderer.invoke('verify-signature', publicKey, message, signature),
|
||||||
deriveAuthKeys: (password, salt) => ipcRenderer.invoke('derive-auth-keys', password, salt),
|
deriveAuthKeys: (password, salt) => ipcRenderer.invoke('derive-auth-keys', password, salt),
|
||||||
encryptData: (data, key) => ipcRenderer.invoke('encrypt-data', data, key),
|
encryptData: (data, key) => ipcRenderer.invoke('encrypt-data', data, key),
|
||||||
decryptData: (encryptedData, key, iv, tag) => ipcRenderer.invoke('decrypt-data', encryptedData, key, iv, tag),
|
decryptData: (encryptedData, key, iv, tag) => ipcRenderer.invoke('decrypt-data', encryptedData, key, iv, tag),
|
||||||
|
|||||||
@@ -5,7 +5,91 @@ import remarkGfm from 'remark-gfm';
|
|||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
|
||||||
const ChatArea = ({ channelId, username }) => {
|
// Cache for link metadata to prevent pop-in
|
||||||
|
const metadataCache = new Map();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (metadataCache.has(url)) {
|
||||||
|
setMetadata(metadataCache.get(url));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
const fetchMeta = async () => {
|
||||||
|
try {
|
||||||
|
const data = await window.cryptoAPI.fetchMetadata(url);
|
||||||
|
if (isMounted) {
|
||||||
|
if (data) metadataCache.set(url, data);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChatArea = ({ channelId, channelName, username }) => {
|
||||||
const [messages, setMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [socket, setSocket] = useState(null);
|
const [socket, setSocket] = useState(null);
|
||||||
@@ -50,77 +134,20 @@ const ChatArea = ({ channelId, username }) => {
|
|||||||
return text.match(urlRegex) || [];
|
return text.match(urlRegex) || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const LinkPreview = ({ url }) => {
|
// Helper to verify message signature
|
||||||
const [metadata, setMetadata] = useState(null);
|
const verifyMessage = async (msg) => {
|
||||||
const [loading, setLoading] = useState(true);
|
if (!msg.signature || !msg.public_signing_key) return false;
|
||||||
const [playing, setPlaying] = useState(false);
|
try {
|
||||||
|
const isValid = await window.cryptoAPI.verifySignature(
|
||||||
useEffect(() => {
|
msg.public_signing_key,
|
||||||
let isMounted = true;
|
msg.ciphertext,
|
||||||
const fetchMeta = async () => {
|
msg.signature
|
||||||
try {
|
);
|
||||||
const data = await window.cryptoAPI.fetchMetadata(url);
|
return isValid;
|
||||||
if (isMounted) {
|
} catch (e) {
|
||||||
setMetadata(data);
|
console.error('Verification error for msg:', msg.id, e);
|
||||||
setLoading(false);
|
return 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(() => {
|
useEffect(() => {
|
||||||
@@ -130,16 +157,18 @@ const ChatArea = ({ channelId, username }) => {
|
|||||||
newSocket.emit('join_channel', channelId);
|
newSocket.emit('join_channel', channelId);
|
||||||
|
|
||||||
newSocket.on('recent_messages', async (msgs) => {
|
newSocket.on('recent_messages', async (msgs) => {
|
||||||
const decryptedMessages = await Promise.all(msgs.map(async (msg) => {
|
const processedMessages = await Promise.all(msgs.map(async (msg) => {
|
||||||
const content = await decryptMessage(msg);
|
const content = await decryptMessage(msg);
|
||||||
return { ...msg, content };
|
const isVerified = await verifyMessage(msg);
|
||||||
|
return { ...msg, content, isVerified };
|
||||||
}));
|
}));
|
||||||
setMessages(decryptedMessages);
|
setMessages(processedMessages);
|
||||||
});
|
});
|
||||||
|
|
||||||
newSocket.on('new_message', async (msg) => {
|
newSocket.on('new_message', async (msg) => {
|
||||||
const content = await decryptMessage(msg);
|
const content = await decryptMessage(msg);
|
||||||
setMessages(prev => [...prev, { ...msg, content }]);
|
const isVerified = await verifyMessage(msg);
|
||||||
|
setMessages(prev => [...prev, { ...msg, content, isVerified }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => newSocket.close();
|
return () => newSocket.close();
|
||||||
@@ -163,20 +192,24 @@ const ChatArea = ({ channelId, username }) => {
|
|||||||
try {
|
try {
|
||||||
const { content: encryptedContent, iv, tag } = await window.cryptoAPI.encryptData(input, DEMO_CHANNEL_KEY);
|
const { content: encryptedContent, iv, tag } = await window.cryptoAPI.encryptData(input, DEMO_CHANNEL_KEY);
|
||||||
const ciphertext = encryptedContent + tag;
|
const ciphertext = encryptedContent + tag;
|
||||||
const signature = 'placeholder_signature';
|
|
||||||
|
|
||||||
const senderId = localStorage.getItem('userId');
|
const senderId = localStorage.getItem('userId');
|
||||||
|
const signingKey = sessionStorage.getItem('signingKey');
|
||||||
|
|
||||||
if (!senderId) {
|
if (!senderId) {
|
||||||
console.error('No userId found in localStorage');
|
console.error('No userId found in localStorage');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!signingKey) {
|
||||||
|
console.error('No signingKey found in sessionStorage. Please relogin.');
|
||||||
|
return; // Prevent sending unsigned messages
|
||||||
|
}
|
||||||
|
|
||||||
const messageData = {
|
const messageData = {
|
||||||
channelId,
|
channelId,
|
||||||
senderId,
|
senderId,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
nonce: iv,
|
nonce: iv,
|
||||||
signature,
|
signature: await window.cryptoAPI.signMessage(signingKey, ciphertext),
|
||||||
keyVersion: 1
|
keyVersion: 1
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -197,81 +230,106 @@ const ChatArea = ({ channelId, username }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="chat-area">
|
<div className="chat-area">
|
||||||
<div className="messages-list">
|
<div className="messages-list">
|
||||||
{messages.map((msg, idx) => {
|
<div className="messages-content-wrapper">
|
||||||
const urls = extractUrls(msg.content);
|
{messages.map((msg, idx) => {
|
||||||
return (
|
const urls = extractUrls(msg.content);
|
||||||
<div key={idx} className="message-item">
|
const currentDate = new Date(msg.created_at);
|
||||||
<div className="message-avatar-wrapper">
|
const previousDate = idx > 0 ? new Date(messages[idx - 1].created_at) : null;
|
||||||
<div
|
|
||||||
className="message-avatar"
|
const isNewDay = !previousDate || (
|
||||||
style={{ backgroundColor: getUserColor(msg.username || 'Unknown') }}
|
currentDate.getDate() !== previousDate.getDate() ||
|
||||||
>
|
currentDate.getMonth() !== previousDate.getMonth() ||
|
||||||
{(msg.username || '?').substring(0, 1).toUpperCase()}
|
currentDate.getFullYear() !== previousDate.getFullYear()
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={idx}>
|
||||||
|
{isNewDay && (
|
||||||
|
<div className="date-divider">
|
||||||
|
<span>{currentDate.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div 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>
|
||||||
|
{!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 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>
|
||||||
</div>
|
</React.Fragment>
|
||||||
<div className="message-body">
|
);
|
||||||
<div className="message-header">
|
})}
|
||||||
<span className="username" style={{ color: getUserColor(msg.username || 'Unknown') }}>
|
<div ref={messagesEndRef} />
|
||||||
{msg.username || 'Unknown'}
|
</div>
|
||||||
</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 ref={messagesEndRef} />
|
|
||||||
</div>
|
</div>
|
||||||
<form className="chat-input-form" onSubmit={handleSend}>
|
<form className="chat-input-form" onSubmit={handleSend}>
|
||||||
<div className="chat-input-wrapper">
|
<div className="chat-input-wrapper">
|
||||||
@@ -285,7 +343,7 @@ const ChatArea = ({ channelId, username }) => {
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={`Message #${channelId}`}
|
placeholder={`Message #${channelName || channelId}`}
|
||||||
rows={1}
|
rows={1}
|
||||||
style={{ height: '44px' }}
|
style={{ height: '44px' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg-primary: #36393f;
|
--bg-primary: #1a1a1e;
|
||||||
--bg-secondary: #2f3136;
|
--bg-secondary: #121214;
|
||||||
--bg-tertiary: #202225;
|
--bg-tertiary: #121214;
|
||||||
|
--div-border: #222225;
|
||||||
--text-normal: #dcddde;
|
--text-normal: #dcddde;
|
||||||
--text-muted: #72767d;
|
--text-muted: #72767d;
|
||||||
--header-primary: #ffffff;
|
--header-primary: #ffffff;
|
||||||
@@ -18,7 +19,8 @@
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: 'Whitney', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
font-family: "gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
;
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
color: var(--text-normal);
|
color: var(--text-normal);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@@ -125,15 +127,18 @@ body {
|
|||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-list {
|
.server-list {
|
||||||
width: 72px;
|
width: 72px;
|
||||||
|
border-right: 1px solid var(--div-border);
|
||||||
background-color: var(--bg-tertiary);
|
background-color: var(--bg-tertiary);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-icon {
|
.server-icon {
|
||||||
@@ -194,6 +199,8 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
|
overflow: hidden;
|
||||||
|
/* Ensure no scrollbars on body/container */
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-area {
|
.chat-area {
|
||||||
@@ -202,6 +209,8 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
/* CRITICAL: Allows flex item to shrink below content size */
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-list {
|
.messages-list {
|
||||||
@@ -263,7 +272,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
font-size: 16px;
|
font-size: 1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-right: 0.25rem;
|
margin-right: 0.25rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -348,7 +357,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-wrapper {
|
.chat-input-wrapper {
|
||||||
background-color: #40444b;
|
background-color: #222327;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -427,7 +436,11 @@ body {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
|
width: 100%;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
/* Ensure content stays inside */
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-content {
|
.preview-content {
|
||||||
@@ -451,21 +464,31 @@ body {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
display: block;
|
display: block;
|
||||||
white-space: nowrap;
|
word-wrap: break-word;
|
||||||
overflow: hidden;
|
/* Ensure long words don't overflow */
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-title:hover {
|
.preview-title:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* .messages-content-wrapper */
|
||||||
|
.messages-content-wrapper {
|
||||||
|
/* Use margin-top: auto to push content to bottom safely */
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ... existing styles ... */
|
||||||
|
|
||||||
.preview-description {
|
.preview-description {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #dcddde;
|
color: #dcddde;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -539,3 +562,37 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.date-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 24px 16px 8px 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-divider::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background-color: #42454a;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-divider span {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
padding: 0 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-failed {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
@@ -27,7 +27,10 @@ const Chat = () => {
|
|||||||
activeChannel={activeChannel}
|
activeChannel={activeChannel}
|
||||||
onSelectChannel={setActiveChannel}
|
onSelectChannel={setActiveChannel}
|
||||||
/>
|
/>
|
||||||
<ChatArea channelId={activeChannel} />
|
<ChatArea
|
||||||
|
channelId={activeChannel}
|
||||||
|
channelName={channels.find(c => c.id === activeChannel)?.name || activeChannel}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,6 +51,33 @@ const Login = () => {
|
|||||||
console.error('MISSING USERID IN VERIFY RESPONSE!', verifyData);
|
console.error('MISSING USERID IN VERIFY RESPONSE!', verifyData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Decrypt Master Key (using DEK)
|
||||||
|
console.log('Decrypting Master Key...');
|
||||||
|
const encryptedMKObj = JSON.parse(verifyData.encryptedMK);
|
||||||
|
const mkHex = await window.cryptoAPI.decryptData(
|
||||||
|
encryptedMKObj.content,
|
||||||
|
dek,
|
||||||
|
encryptedMKObj.iv,
|
||||||
|
encryptedMKObj.tag
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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; // Already an object
|
||||||
|
const signingKey = await window.cryptoAPI.decryptData(
|
||||||
|
edPrivObj.content,
|
||||||
|
mkHex, // MK acts as the key
|
||||||
|
edPrivObj.iv,
|
||||||
|
edPrivObj.tag
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store Signing Key in Session (Memory-like) storage
|
||||||
|
sessionStorage.setItem('signingKey', signingKey);
|
||||||
|
console.log('Signing Key decrypted and stored in session.');
|
||||||
|
|
||||||
localStorage.setItem('username', username);
|
localStorage.setItem('username', username);
|
||||||
|
|
||||||
// Verify immediate read back
|
// Verify immediate read back
|
||||||
|
|||||||
Reference in New Issue
Block a user