From 56a9523e38b2ffe702055a3fd03e400d103254c1 Mon Sep 17 00:00:00 2001
From: Bryan1029384756 <23323626+Bryan1029384756@users.noreply.github.com>
Date: Fri, 13 Feb 2026 07:18:19 -0600
Subject: [PATCH] feat: implement initial Electron chat application with core
UI components and Convex backend integration.
---
Frontend/Electron/main.cjs | 5 +
Frontend/Electron/package.json | 2 +-
Frontend/Electron/src/App.jsx | 9 +
Frontend/Electron/src/components/ChatArea.jsx | 9 +
.../src/components/PinnedMessagesPanel.jsx | 193 ++++++++++++++----
Frontend/Electron/src/components/Sidebar.jsx | 30 ++-
.../Electron/src/contexts/VoiceContext.jsx | 89 +++++++-
Frontend/Electron/src/index.css | 34 +--
Frontend/Electron/src/pages/Chat.jsx | 20 +-
Frontend/Electron/src/pages/Register.jsx | 17 ++
.../Electron/src/utils/userPreferences.js | 4 +
TODO.md | 9 +-
convex/messages.ts | 147 +++++++------
convex/schema.ts | 4 +-
convex/voiceState.ts | 42 ++++
15 files changed, 436 insertions(+), 178 deletions(-)
diff --git a/Frontend/Electron/main.cjs b/Frontend/Electron/main.cjs
index 775a9b4..c45fb2b 100644
--- a/Frontend/Electron/main.cjs
+++ b/Frontend/Electron/main.cjs
@@ -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();
diff --git a/Frontend/Electron/package.json b/Frontend/Electron/package.json
index 2d467ac..145ef3a 100644
--- a/Frontend/Electron/package.json
+++ b/Frontend/Electron/package.json
@@ -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",
diff --git a/Frontend/Electron/src/App.jsx b/Frontend/Electron/src/App.jsx
index 2f21cf0..e7d0486 100644
--- a/Frontend/Electron/src/App.jsx
+++ b/Frontend/Electron/src/App.jsx
@@ -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;
}
diff --git a/Frontend/Electron/src/components/ChatArea.jsx b/Frontend/Electron/src/components/ChatArea.jsx
index 0bb6235..7e7f142 100644
--- a/Frontend/Electron/src/components/ChatArea.jsx
+++ b/Frontend/Electron/src/components/ChatArea.jsx
@@ -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}
/>
diff --git a/Frontend/Electron/src/components/PinnedMessagesPanel.jsx b/Frontend/Electron/src/components/PinnedMessagesPanel.jsx
index 61b603a..091b512 100644
--- a/Frontend/Electron/src/components/PinnedMessagesPanel.jsx
+++ b/Frontend/Electron/src/components/PinnedMessagesPanel.jsx
@@ -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 (
@@ -53,33 +133,60 @@ const PinnedMessagesPanel = ({ channelId, visible, onClose, channelKey, onJumpTo
No pinned messages in this channel yet.
) : (
- decryptedPins.map(msg => (
-
-
-
{msg.username}
-
- {new Date(msg.created_at).toLocaleDateString()}
-
+ decryptedPins.map(msg => {
+ const isOwner = msg.username === username;
+ return (
+
+
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}
+ />
+
+
+
+
-
- {msg.content?.startsWith('{') ? '[Attachment]' : msg.content}
-
-
-
-
-
-
- ))
+ );
+ })
)}
diff --git a/Frontend/Electron/src/components/Sidebar.jsx b/Frontend/Electron/src/components/Sidebar.jsx
index 285997d..15e9741 100644
--- a/Frontend/Electron/src/components/Sidebar.jsx
+++ b/Frontend/Electron/src/components/Sidebar.jsx
@@ -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
diff --git a/Frontend/Electron/src/contexts/VoiceContext.jsx b/Frontend/Electron/src/contexts/VoiceContext.jsx
index 2aad4e4..ecdc5a0 100644
--- a/Frontend/Electron/src/contexts/VoiceContext.jsx
+++ b/Frontend/Electron/src/contexts/VoiceContext.jsx
@@ -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();
diff --git a/Frontend/Electron/src/index.css b/Frontend/Electron/src/index.css
index 4b02e2d..e4fae66 100644
--- a/Frontend/Electron/src/index.css
+++ b/Frontend/Electron/src/index.css
@@ -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 {
diff --git a/Frontend/Electron/src/pages/Chat.jsx b/Frontend/Electron/src/pages/Chat.jsx
index 4e86d4d..661674a 100644
--- a/Frontend/Electron/src/pages/Chat.jsx
+++ b/Frontend/Electron/src/pages/Chat.jsx
@@ -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');
diff --git a/Frontend/Electron/src/pages/Register.jsx b/Frontend/Electron/src/pages/Register.jsx
index b10ecc2..0f827e8 100644
--- a/Frontend/Electron/src/pages/Register.jsx
+++ b/Frontend/Electron/src/pages/Register.jsx
@@ -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}
/>
+