feat: implement initial Electron chat application with core UI components and Convex backend integration.
All checks were successful
Build and Release / build-and-release (push) Successful in 11m1s

This commit is contained in:
Bryan1029384756
2026-02-13 07:18:19 -06:00
parent 2201c56cb2
commit 56a9523e38
15 changed files with 436 additions and 178 deletions

View File

@@ -79,6 +79,11 @@ function createWindow() {
// Save window state on close
mainWindow.on('close', () => {
// Flush localStorage/sessionStorage to disk before renderer is destroyed
// (Chromium writes to an in-memory cache and flushes asynchronously;
// without this, data written just before close can be lost)
mainWindow.webContents.session.flushStorageData();
const current = loadSettings(); // re-read to preserve theme changes
if (!mainWindow.isMaximized()) {
const bounds = mainWindow.getBounds();

View File

@@ -1,7 +1,7 @@
{
"name": "discord",
"private": true,
"version": "1.0.10",
"version": "1.0.11",
"description": "A Discord clone built with Convex, React, and Electron",
"author": "Moyettes",
"type": "module",

View File

@@ -32,6 +32,15 @@ function AuthGuard({ children }) {
if (session.publicKey) localStorage.setItem('publicKey', session.publicKey);
sessionStorage.setItem('signingKey', session.signingKey);
sessionStorage.setItem('privateKey', session.privateKey);
// Restore user preferences from file-based backup into localStorage
if (window.appSettings) {
try {
const savedPrefs = await window.appSettings.get(`userPrefs_${session.userId}`);
if (savedPrefs && typeof savedPrefs === 'object') {
localStorage.setItem(`userPrefs_${session.userId}`, JSON.stringify(savedPrefs));
}
} catch {}
}
if (!cancelled) setAuthState('authenticated');
return;
}

View File

@@ -1236,6 +1236,15 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
onClose={onTogglePinned}
channelKey={channelKey}
onJumpToMessage={scrollToMessage}
userId={currentUserId}
username={username}
roles={roles}
Attachment={Attachment}
LinkPreview={LinkPreview}
DirectVideo={DirectVideo}
onReactionClick={handleReactionClick}
onProfilePopup={handleProfilePopup}
onImageClick={setZoomedImage}
/>
<div className="messages-list" ref={messagesContainerRef}>

View File

@@ -1,13 +1,31 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import MessageItem from './MessageItem';
const PinnedMessagesPanel = ({ channelId, visible, onClose, channelKey, onJumpToMessage }) => {
const TAG_LENGTH = 32;
const PinnedMessagesPanel = ({
channelId,
visible,
onClose,
channelKey,
onJumpToMessage,
userId,
username,
roles,
Attachment,
LinkPreview,
DirectVideo,
onReactionClick,
onProfilePopup,
onImageClick,
}) => {
const [decryptedPins, setDecryptedPins] = useState([]);
const pinnedMessages = useQuery(
api.messages.listPinned,
channelId ? { channelId } : "skip"
channelId ? { channelId, userId: userId || undefined } : "skip"
) || [];
const unpinMutation = useMutation(api.messages.pin);
@@ -19,28 +37,90 @@ const PinnedMessagesPanel = ({ channelId, visible, onClose, channelKey, onJumpTo
}
let cancelled = false;
const decrypt = async () => {
const results = await Promise.all(
pinnedMessages.map(async (msg) => {
try {
const TAG_LENGTH = 32;
const tag = msg.ciphertext.slice(-TAG_LENGTH);
const content = msg.ciphertext.slice(0, -TAG_LENGTH);
const decrypted = await window.cryptoAPI.decryptData(content, channelKey, msg.nonce, tag);
return { ...msg, content: decrypted };
} catch {
return { ...msg, content: '[Encrypted Message]' };
}
})
);
const decryptAll = async () => {
// Build batch arrays for message decryption
const decryptItems = [];
const decryptMsgMap = [];
const replyDecryptItems = [];
const replyMsgMap = [];
const verifyItems = [];
const verifyMsgMap = [];
for (const msg of pinnedMessages) {
if (msg.ciphertext && msg.ciphertext.length >= TAG_LENGTH) {
const tag = msg.ciphertext.slice(-TAG_LENGTH);
const content = msg.ciphertext.slice(0, -TAG_LENGTH);
decryptItems.push({ ciphertext: content, key: channelKey, iv: msg.nonce, tag });
decryptMsgMap.push(msg);
}
if (msg.replyToContent && msg.replyToNonce) {
const rTag = msg.replyToContent.slice(-TAG_LENGTH);
const rContent = msg.replyToContent.slice(0, -TAG_LENGTH);
replyDecryptItems.push({ ciphertext: rContent, key: channelKey, iv: msg.replyToNonce, tag: rTag });
replyMsgMap.push(msg);
}
if (msg.signature && msg.public_signing_key) {
verifyItems.push({ publicKey: msg.public_signing_key, message: msg.ciphertext, signature: msg.signature });
verifyMsgMap.push(msg);
}
}
const [decryptResults, replyResults, verifyResults] = await Promise.all([
decryptItems.length > 0 ? window.cryptoAPI.decryptBatch(decryptItems) : [],
replyDecryptItems.length > 0 ? window.cryptoAPI.decryptBatch(replyDecryptItems) : [],
verifyItems.length > 0 ? window.cryptoAPI.verifyBatch(verifyItems) : [],
]);
if (cancelled) return;
const decryptedMap = new Map();
for (let i = 0; i < decryptResults.length; i++) {
const msg = decryptMsgMap[i];
const result = decryptResults[i];
decryptedMap.set(msg.id, result.success ? result.data : '[Decryption Error]');
}
const replyMap = new Map();
for (let i = 0; i < replyResults.length; i++) {
const msg = replyMsgMap[i];
const result = replyResults[i];
if (result.success) {
let text = result.data;
if (text.startsWith('{')) text = '[Attachment]';
else if (text.length > 100) text = text.substring(0, 100) + '...';
replyMap.set(msg.id, text);
} else {
replyMap.set(msg.id, '[Encrypted]');
}
}
const verifyMap = new Map();
for (let i = 0; i < verifyResults.length; i++) {
const msg = verifyMsgMap[i];
verifyMap.set(msg.id, verifyResults[i].success && verifyResults[i].verified);
}
const results = pinnedMessages.map(msg => ({
...msg,
content: decryptedMap.get(msg.id) ?? '[Encrypted Message]',
isVerified: verifyMap.get(msg.id) ?? false,
decryptedReply: replyMap.get(msg.id) ?? null,
}));
if (!cancelled) setDecryptedPins(results);
};
decrypt();
decryptAll();
return () => { cancelled = true; };
}, [pinnedMessages, channelKey]);
if (!visible) return null;
const noop = () => {};
return (
<div className="pinned-panel">
<div className="pinned-panel-header">
@@ -53,33 +133,60 @@ const PinnedMessagesPanel = ({ channelId, visible, onClose, channelKey, onJumpTo
No pinned messages in this channel yet.
</div>
) : (
decryptedPins.map(msg => (
<div key={msg.id} className="pinned-message-item">
<div className="pinned-message-header">
<span className="pinned-message-author">{msg.username}</span>
<span className="pinned-message-date">
{new Date(msg.created_at).toLocaleDateString()}
</span>
decryptedPins.map(msg => {
const isOwner = msg.username === username;
return (
<div key={msg.id} className="pinned-message-card">
<MessageItem
msg={msg}
isGrouped={false}
showDateDivider={false}
showUnreadDivider={false}
dateLabel=""
isMentioned={false}
isOwner={isOwner}
isEditing={false}
isHovered={false}
editInput=""
username={username}
roles={roles}
onHover={noop}
onLeave={noop}
onContextMenu={(e) => e.preventDefault()}
onAddReaction={noop}
onEdit={noop}
onReply={noop}
onMore={noop}
onEditInputChange={noop}
onEditKeyDown={noop}
onEditSave={noop}
onEditCancel={noop}
onReactionClick={onReactionClick}
onScrollToMessage={onJumpToMessage}
onProfilePopup={onProfilePopup}
onImageClick={onImageClick}
scrollToBottom={noop}
Attachment={Attachment}
LinkPreview={LinkPreview}
DirectVideo={DirectVideo}
/>
<div className="pinned-message-actions">
<button
className="pinned-action-btn"
onClick={() => onJumpToMessage && onJumpToMessage(msg.id)}
>
Jump
</button>
<button
className="pinned-action-btn pinned-action-danger"
onClick={() => unpinMutation({ id: msg.id, pinned: false })}
>
Unpin
</button>
</div>
</div>
<div className="pinned-message-content">
{msg.content?.startsWith('{') ? '[Attachment]' : msg.content}
</div>
<div className="pinned-message-actions">
<button
className="pinned-action-btn"
onClick={() => onJumpToMessage && onJumpToMessage(msg.id)}
>
Jump
</button>
<button
className="pinned-action-btn pinned-action-danger"
onClick={() => unpinMutation({ id: msg.id, pinned: false })}
>
Unpin
</button>
</div>
</div>
))
);
})
)}
</div>
</div>

View File

@@ -152,10 +152,20 @@ const UserControlPanel = ({ username, userId }) => {
if (window.sessionPersistence) {
try { await window.sessionPersistence.clear(); } catch {}
}
// Clear storage (preserve theme)
// Clear storage (preserve theme and user preferences)
const theme = localStorage.getItem('theme');
const savedPrefs = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key.startsWith('userPrefs_')) {
savedPrefs[key] = localStorage.getItem(key);
}
}
localStorage.clear();
if (theme) localStorage.setItem('theme', theme);
for (const [key, value] of Object.entries(savedPrefs)) {
localStorage.setItem(key, value);
}
sessionStorage.clear();
navigate('/');
};
@@ -749,7 +759,15 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
const [newChannelType, setNewChannelType] = useState('text');
const [editingChannel, setEditingChannel] = useState(null);
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
const [collapsedCategories, setCollapsedCategories] = useState(() => getUserPref(userId, 'collapsedCategories', {}));
const [collapsedCategories, setCollapsedCategories] = useState(() => {
const effectiveUserId = userId || localStorage.getItem('userId');
return getUserPref(effectiveUserId, 'collapsedCategories', {});
});
useEffect(() => {
if (userId) {
setCollapsedCategories(getUserPref(userId, 'collapsedCategories', {}));
}
}, [userId]);
const [channelListContextMenu, setChannelListContextMenu] = useState(null);
const [voiceUserMenu, setVoiceUserMenu] = useState(null);
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false);
@@ -1107,11 +1125,9 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
};
const toggleCategory = (cat) => {
setCollapsedCategories(prev => {
const next = { ...prev, [cat]: !prev[cat] };
setUserPref(userId, 'collapsedCategories', next);
return next;
});
const next = { ...collapsedCategories, [cat]: !collapsedCategories[cat] };
setCollapsedCategories(next);
setUserPref(userId, 'collapsedCategories', next);
};
// Group channels by categoryId

View File

@@ -12,6 +12,8 @@ import muteSound from '../assets/sounds/mute.mp3';
import unmuteSound from '../assets/sounds/unmute.mp3';
import deafenSound from '../assets/sounds/deafen.mp3';
import undeafenSound from '../assets/sounds/undeafen.mp3';
import viewerJoinSound from '../assets/sounds/screenshare_viewer_join.mp3';
import viewerLeaveSound from '../assets/sounds/screenshare_viewer_leave.mp3';
const soundMap = {
join: joinSound,
@@ -19,7 +21,9 @@ const soundMap = {
mute: muteSound,
unmute: unmuteSound,
deafen: deafenSound,
undeafen: undeafenSound
undeafen: undeafenSound,
viewer_join: viewerJoinSound,
viewer_leave: viewerLeaveSound,
};
const VoiceContext = createContext();
@@ -46,12 +50,36 @@ export const VoiceProvider = ({ children }) => {
const [isScreenSharing, setIsScreenSharingLocal] = useState(false);
const isMovingRef = useRef(false);
const convex = useConvex();
// Stream watching state (lifted from VoiceStage so PiP can persist across navigation)
const [watchingStreamOf, setWatchingStreamOfRaw] = useState(null);
const setWatchingStreamOf = useCallback((identity) => {
setWatchingStreamOfRaw(identity);
}, []);
// Sync to backend
const userId = localStorage.getItem('userId');
if (userId) {
convex.mutation(api.voiceState.setWatchingStream, {
userId,
...(identity ? { watchingStream: identity } : {}),
}).catch(e => console.error('Failed to set watching stream:', e));
}
// Play join sound for the viewer starting to watch
if (identity) {
playSound('viewer_join');
}
}, [convex]);
const clearWatchingStream = useCallback(() => {
setWatchingStreamOfRaw(null);
const userId = localStorage.getItem('userId');
if (userId) {
convex.mutation(api.voiceState.setWatchingStream, { userId }).catch(
e => console.error('Failed to clear watching stream:', e)
);
}
}, [convex]);
// Personal mute state (persisted to localStorage)
const [personallyMutedUsers, setPersonallyMutedUsers] = useState(() => {
@@ -127,8 +155,6 @@ export const VoiceProvider = ({ children }) => {
const isPersonallyMuted = (userId) => personallyMutedUsers.has(userId);
const convex = useConvex();
const serverMute = async (targetUserId, isServerMuted) => {
const actorUserId = localStorage.getItem('userId');
if (!actorUserId) return;
@@ -389,14 +415,14 @@ export const VoiceProvider = ({ children }) => {
|| (room.localParticipant.identity === watchingStreamOf ? room.localParticipant : null);
if (!participant) {
setWatchingStreamOfRaw(null);
clearWatchingStream();
return;
}
// Check if they're still screen sharing
const { screenSharePub } = findTrackPubs(participant);
if (!screenSharePub) {
setWatchingStreamOfRaw(null);
clearWatchingStream();
}
};
@@ -413,10 +439,59 @@ export const VoiceProvider = ({ children }) => {
// Reset watching state when room disconnects
useEffect(() => {
if (!room) {
setWatchingStreamOfRaw(null);
clearWatchingStream();
}
}, [room]);
// Detect viewer join/leave for the stream we're watching and play sounds
const prevViewersRef = useRef(new Set());
const viewerDetectionInitRef = useRef(false);
useEffect(() => {
if (!watchingStreamOf) {
prevViewersRef.current = new Set();
viewerDetectionInitRef.current = false;
return;
}
const myUserId = localStorage.getItem('userId');
// Collect all users currently watching the same stream
const currentViewers = new Set();
for (const users of Object.values(voiceStates)) {
for (const u of users) {
if (u.watchingStream === watchingStreamOf && u.userId !== myUserId) {
currentViewers.add(u.userId);
}
}
}
// Skip first render to avoid spurious sounds on load
if (!viewerDetectionInitRef.current) {
viewerDetectionInitRef.current = true;
prevViewersRef.current = currentViewers;
return;
}
const prev = prevViewersRef.current;
// New viewers joined
for (const uid of currentViewers) {
if (!prev.has(uid)) {
playSound('viewer_join');
break; // one sound per update batch
}
}
// Viewers left (excluding self)
for (const uid of prev) {
if (!currentViewers.has(uid)) {
playSound('viewer_leave');
break; // one sound per update batch
}
}
prevViewersRef.current = currentViewers;
}, [voiceStates, watchingStreamOf]);
const disconnectVoice = () => {
console.log('User manually disconnected voice');
if (room) room.disconnect();

View File

@@ -1596,42 +1596,30 @@ body {
padding: 32px 16px;
}
.pinned-message-item {
.pinned-message-card {
background-color: var(--bg-primary);
border-radius: 4px;
padding: 12px;
border-radius: 8px;
margin-bottom: 8px;
overflow: hidden;
}
.pinned-message-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
.pinned-message-card .message-item {
padding: 8px 12px 4px;
}
.pinned-message-author {
color: var(--header-primary);
font-weight: 600;
font-size: 14px;
.pinned-message-card .message-toolbar {
display: none !important;
}
.pinned-message-date {
color: var(--text-muted);
font-size: 12px;
}
.pinned-message-content {
color: var(--text-normal);
font-size: 14px;
line-height: 1.4;
margin-bottom: 8px;
word-break: break-word;
.pinned-message-card .message-content img,
.pinned-message-card .message-content video {
max-width: 100%;
}
.pinned-message-actions {
display: flex;
gap: 8px;
padding: 4px 12px 10px 68px;
}
.pinned-action-btn {

View File

@@ -14,10 +14,13 @@ import { PresenceProvider } from '../contexts/PresenceContext';
import { getUserPref, setUserPref } from '../utils/userPreferences';
const Chat = () => {
const [view, setView] = useState('server');
const [userId, setUserId] = useState(() => localStorage.getItem('userId'));
const [username, setUsername] = useState(() => localStorage.getItem('username') || '');
const [view, setView] = useState(() => {
const id = localStorage.getItem('userId');
return id ? getUserPref(id, 'lastView', 'server') : 'server';
});
const [activeChannel, setActiveChannel] = useState(null);
const [username, setUsername] = useState('');
const [userId, setUserId] = useState(null);
const [channelKeys, setChannelKeys] = useState({});
const [activeDMChannel, setActiveDMChannel] = useState(null);
const [showMembers, setShowMembers] = useState(true);
@@ -57,17 +60,6 @@ const Chat = () => {
userId ? { userId } : "skip"
) || [];
useEffect(() => {
const storedUsername = localStorage.getItem('username');
const storedUserId = localStorage.getItem('userId');
if (storedUsername) setUsername(storedUsername);
if (storedUserId) {
setUserId(storedUserId);
const savedView = getUserPref(storedUserId, 'lastView', 'server');
setView(savedView);
}
}, []);
useEffect(() => {
if (!rawChannelKeys || rawChannelKeys.length === 0) return;
const privateKey = sessionStorage.getItem('privateKey');

View File

@@ -13,6 +13,7 @@ function parseInviteParams(input) {
const Register = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [inviteKeys, setInviteKeys] = useState(null);
@@ -70,6 +71,12 @@ const Register = () => {
const handleRegister = async (e) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
setLoading(true);
try {
@@ -176,6 +183,16 @@ const Register = () => {
disabled={loading}
/>
</div>
<div className="form-group">
<label>Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={loading}
/>
</div>
<button type="submit" className="auth-button" disabled={loading}>
{loading ? 'Generating Keys...' : 'Continue'}
</button>

View File

@@ -17,6 +17,10 @@ export function setUserPref(userId, key, value) {
const prefs = raw ? JSON.parse(raw) : {};
prefs[key] = value;
localStorage.setItem(`userPrefs_${userId}`, JSON.stringify(prefs));
// Also persist to disk via Electron IPC (fire-and-forget)
if (window.appSettings) {
window.appSettings.set(`userPrefs_${userId}`, prefs);
}
} catch {
// Silently fail on corrupt data or full storage
}