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
All checks were successful
Build and Release / build-and-release (push) Successful in 11m1s
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user