Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/src/lib/socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ export const initializeSocket = (httpServer) => {
io.on("connection", (socket) => {
console.log("New socket connection:", socket.id);

// Allow clients to request a fresh snapshot at any time (prevents UI from
// getting stuck in "checking" if it mounted after the last broadcast).
socket.on("active_users:request", () => {
socket.emit("active_users", Array.from(activeUsers.keys()));
});

// ── User Connected ──────────────────────────────────
socket.on("user_connected", async (userId) => {
if (!activeUsers.has(userId)) {
Expand Down
90 changes: 73 additions & 17 deletions frontend/src/Components/ChatContainer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { getMessages, sendMessage, deleteForMe, deleteForEveryone, editMessage,
import { getSocket, emitTyping, emitStoppedTyping, emitMarkAsRead, getActiveUsers } from '../services/socket';
import { ThemeContext } from '../contexts/ThemeContext';
import { encryptMessage, decryptMessage, decryptMessagesBatch, isCryptoReady, hasSession, getOrCreateSession, resetSession, isE2EESupported } from '../services/keyManager';
import { cacheDecryptedMessage } from '../services/sessionStore';
import { cacheDecryptedMessage, cacheDecryptedMessageByKey } from '../services/sessionStore';

const CustomAudioPlayer = ({ src }) => {
const [isPlaying, setIsPlaying] = useState(false);
Expand Down Expand Up @@ -353,18 +353,33 @@ const ChatContainer = ({ selectedContact, authUser, onLogout, onStartCall }) =>

// WebSocket states
const [isContactTyping, setIsContactTyping] = useState(false);
const [contactOnlineStatus, setContactOnlineStatus] = useState('offline');
const [contactOnlineStatus, setContactOnlineStatus] = useState('checking');
const typingTimeoutRef = useRef(null);
const activeUsersRef = useRef(getActiveUsers());
const hasPresenceSnapshotRef = useRef(false);

// Prime presence snapshot from cached activeUsers in socket service.
useEffect(() => {
if (selectedContact?._id) {
setContactOnlineStatus(
getActiveUsers().includes(selectedContact._id) ? 'online' : 'offline'
);
} else {
const cached = getActiveUsers();
if (Array.isArray(cached) && cached.length > 0) {
activeUsersRef.current = cached;
hasPresenceSnapshotRef.current = true;
}
}, []);

// Avoid flashing "Offline" before we receive an active_users snapshot.
useEffect(() => {
if (!selectedContact?._id) {
setContactOnlineStatus('offline');
return;
}
if (!hasPresenceSnapshotRef.current) {
setContactOnlineStatus('checking');
return;
}
setContactOnlineStatus(
activeUsersRef.current.includes(selectedContact._id) ? 'online' : 'offline'
);
}, [selectedContact?._id]);

// Recording timer and click outside effects
Expand Down Expand Up @@ -512,9 +527,13 @@ const ChatContainer = ({ selectedContact, authUser, onLogout, onStartCall }) =>
// ── Set initial online status when contact changes using cached active users ──
useEffect(() => {
if (selectedContact?._id) {
setContactOnlineStatus(
activeUsersRef.current.includes(selectedContact._id) ? 'online' : 'offline'
);
if (!hasPresenceSnapshotRef.current) {
setContactOnlineStatus('checking');
} else {
setContactOnlineStatus(
activeUsersRef.current.includes(selectedContact._id) ? 'online' : 'offline'
);
}
} else {
setContactOnlineStatus('offline');
}
Expand Down Expand Up @@ -633,6 +652,7 @@ const ChatContainer = ({ selectedContact, authUser, onLogout, onStartCall }) =>
const handleActiveUsers = (activeUserIds) => {
// Always update the ref with the latest list
activeUsersRef.current = activeUserIds;
hasPresenceSnapshotRef.current = true;
if (selectedContact?._id) {
setContactOnlineStatus(
activeUserIds.includes(selectedContact._id) ? 'online' : 'offline'
Expand Down Expand Up @@ -989,15 +1009,24 @@ const ChatContainer = ({ selectedContact, authUser, onLogout, onStartCall }) =>
const encrypted = await encryptMessage(authUser._id, selectedContact._id, text);
if (encrypted) {
console.log(`[E2EE] Message encrypted successfully`);
// Cache plaintext early using a deterministic fingerprint so a reload/tab close
// before the server returns _id still allows preview/decryption later.
const fpKey = `${encrypted.encryptionVersion || "e2ee"}|${encrypted.nonce}|${encrypted.ciphertext}|${JSON.stringify(encrypted.ratchetHeader)}`;
cacheDecryptedMessageByKey(fpKey, text).catch(() => { });
payload = {
...encrypted,
messageType: 'text',
};
if (currentReplyTo) payload.replyTo = currentReplyTo._id;
if (!isE2EEActive) setIsE2EEActive(true);
} else {
// Fallback to plaintext if encryption fails (no keys)
console.warn(`[E2EE] Encryption failed for ${selectedContact._id}, falling back to plaintext`);
// If encryption is expected (session supported/active), do NOT silently downgrade to plaintext.
console.warn(`[E2EE] Encryption failed for ${selectedContact._id}`);
const supported = await isE2EESupported(selectedContact._id);
if (supported || isE2EEActive) {
throw new Error("E2EE_ENCRYPTION_REQUIRED");
}
// Otherwise, allow plaintext for users who have not enabled E2EE.
if (text) payload.text = text;
if (currentReplyTo) payload.replyTo = currentReplyTo._id;
}
Expand All @@ -1018,13 +1047,23 @@ const ChatContainer = ({ selectedContact, authUser, onLogout, onStartCall }) =>
// Cache sent message plaintext so it survives chat switching
if (realMsg._id && text) {
cacheDecryptedMessage(realMsg._id, text).catch(() => { });
// Also cache by fingerprint to support StreamPanel preview without id-based cache.
if (realMsg.ciphertext && realMsg.nonce && realMsg.ratchetHeader) {
const fpKey = `${realMsg.encryptionVersion || "e2ee"}|${realMsg.nonce}|${realMsg.ciphertext}|${JSON.stringify(realMsg.ratchetHeader)}`;
cacheDecryptedMessageByKey(fpKey, text).catch(() => { });
}
}
}
setMessages(prev =>
prev.map(m => m._id === optimisticMsg._id ? realMsg : m)
);
// Nudge StreamPanel to refresh immediately even if backend doesn't emit an ack event.
window.dispatchEvent(new CustomEvent('nexus:stream-refresh'));
} catch (err) {
console.error('Failed to send message:', err);
if (err?.message === "E2EE_ENCRYPTION_REQUIRED") {
alert("Couldn’t encrypt this message (E2EE required). Ask the other user to log in/enable E2EE, or try resetting the E2EE session and sending again.");
}
// Remove optimistic message on failure
setMessages(prev => prev.filter(m => m._id !== optimisticMsg._id));
if (text) setMessage(text);
Expand Down Expand Up @@ -1162,8 +1201,15 @@ const ChatContainer = ({ selectedContact, authUser, onLogout, onStartCall }) =>
</div>
</div>
<div className="flex items-center gap-3 mt-1 mb-0">
<div className={`w-2.5 h-2.5 rounded-full ${contactOnlineStatus === 'online' ? 'bg-green-400 shadow-[0_0_10px_rgba(34,197,94,0.45)]' : 'bg-gray-500'}`} />
<span className="text-[12px] text-gray-400/80">{contactOnlineStatus === 'online' ? 'Online' : 'Offline'}</span>
<div className={`w-2.5 h-2.5 rounded-full ${contactOnlineStatus === 'online'
? 'bg-green-400 shadow-[0_0_10px_rgba(34,197,94,0.45)]'
: contactOnlineStatus === 'checking'
? 'bg-yellow-400/70'
: 'bg-gray-500'
}`} />
<span className="text-[12px] text-gray-400/80">
{contactOnlineStatus === 'online' ? 'Online' : contactOnlineStatus === 'checking' ? 'Checking…' : 'Offline'}
</span>
</div>
</div>

Expand Down Expand Up @@ -1798,9 +1844,19 @@ const ChatContainer = ({ selectedContact, authUser, onLogout, onStartCall }) =>
<h2 className="text-[22px] font-normal mb-1.5 text-center" style={{ color: 'var(--text-primary)' }}>
{selectedContact.fullName}
</h2>
<div className={`flex items-center gap-1.5 text-[12px] ${contactOnlineStatus === 'online' ? 'text-[var(--status-online)]' : 'text-[var(--text-tertiary)]'}`}>
<div className={`w-1.5 h-1.5 rounded-full ${contactOnlineStatus === 'online' ? 'bg-[var(--status-online)]' : 'bg-[var(--text-tertiary)]'}`} />
{contactOnlineStatus === 'online' ? 'Online' : 'Offline'}
<div className={`flex items-center gap-1.5 text-[12px] ${contactOnlineStatus === 'online'
? 'text-[var(--status-online)]'
: contactOnlineStatus === 'checking'
? 'text-yellow-400/80'
: 'text-[var(--text-tertiary)]'
}`}>
<div className={`w-1.5 h-1.5 rounded-full ${contactOnlineStatus === 'online'
? 'bg-[var(--status-online)]'
: contactOnlineStatus === 'checking'
? 'bg-yellow-400/70'
: 'bg-[var(--text-tertiary)]'
}`} />
{contactOnlineStatus === 'online' ? 'Online' : contactOnlineStatus === 'checking' ? 'Checking…' : 'Offline'}
</div>
</div>

Expand Down
41 changes: 40 additions & 1 deletion frontend/src/Components/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { updateProfile } from "../api";
import Cropper from "react-easy-crop";
import getCroppedImg, { convertBlobToBase64 } from "../utils/cropImage";
import { useTheme, THEME_LIST } from "../contexts/ThemeContext.jsx";
import { exportAllE2EEKeys, importAllE2EEKeys } from "../services/sessionStore.js";
import { exportAllE2EEKeys, importAllE2EEKeys, clearAllE2EEData } from "../services/sessionStore.js";

function SettingsPanel({ authUser, onLogout, onProfileUpdate }) {
const fileInputRef = useRef(null);
Expand Down Expand Up @@ -56,6 +56,22 @@ function SettingsPanel({ authUser, onLogout, onProfileUpdate }) {
e.target.value = null;
};

const handleResetE2EE = async () => {
if (!authUser?._id) return;
const ok = window.confirm(
"Reset E2EE on this device?\n\nThis will delete local E2EE keys + sessions and force a brand-new handshake. You should export a backup first if you might need old message previews on this device."
);
if (!ok) return;
try {
await clearAllE2EEData();
alert("E2EE reset complete. The app will reload and generate new keys.");
window.location.reload();
} catch (err) {
console.error("Failed to reset E2EE:", err);
alert("Failed to reset E2EE.");
}
};

// Cropping State
const [activeImage, setActiveImage] = useState(null);
const [crop, setCrop] = useState({ x: 0, y: 0 });
Expand Down Expand Up @@ -561,6 +577,29 @@ function SettingsPanel({ authUser, onLogout, onProfileUpdate }) {
/>
</SettingRow>

<SettingRow label="Reset E2EE (new handshake)">
<button
onClick={handleResetE2EE}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
padding: "5px 12px",
borderRadius: "8px",
background: "rgba(239, 68, 68, 0.12)",
border: "1px solid rgba(239, 68, 68, 0.35)",
cursor: "pointer",
color: "rgb(239, 68, 68)",
fontSize: "12px",
fontWeight: 500,
fontFamily: "var(--font-main)"
}}
>
<X size={13} />
Reset
</button>
</SettingRow>

<SettingRow label="Storage & Data" right="→" />
</div>

Expand Down
25 changes: 24 additions & 1 deletion frontend/src/Components/StreamPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import ContextMenu from './ContextMenu';
import { getChatPartners, getContacts, toggleArchiveUser, getArchivedUsers } from '../api';
import { getSocket, getActiveUsers } from '../services/socket';
import { ThemeContext } from '../contexts/ThemeContext';
import { getCachedDecryptedMessages } from '../services/sessionStore';
import { getCachedDecryptedMessages, getCachedDecryptedMessageByKey } from '../services/sessionStore';
import './StreamPanel.css';
// StreamPanel component
const StreamPanel = ({ authUser, selectedContactId, onSelectContact, className }) => {
Expand Down Expand Up @@ -97,6 +97,22 @@ const StreamPanel = ({ authUser, selectedContactId, onSelectContact, className }
}
}

// Fallback: if lastMessage is encrypted but not cached by id, try fingerprint cacheKey.
partners = await Promise.all(partners.map(async (p) => {
const lm = p.lastMessage;
if (!lm || lm.encryptionVersion !== 'e2ee-v1') return p;
if (lm._decryptedPreview) return p;
if (!lm.ciphertext || !lm.nonce || !lm.ratchetHeader) return p;
const fpKey = `${lm.encryptionVersion || "e2ee"}|${lm.nonce}|${lm.ciphertext}|${JSON.stringify(lm.ratchetHeader)}`;
try {
const decrypted = await getCachedDecryptedMessageByKey(fpKey);
if (decrypted) {
return { ...p, lastMessage: { ...lm, _decryptedPreview: decrypted } };
}
} catch { }
return p;
}));

setChatPartners(partners);
} catch (err) {
console.error('Failed to fetch chat partners:', err);
Expand All @@ -121,6 +137,13 @@ const StreamPanel = ({ authUser, selectedContactId, onSelectContact, className }
fetchChatPartners();
}, []);

// Allow other panels (ChatContainer) to trigger an immediate refresh.
useEffect(() => {
const handler = () => fetchChatPartners();
window.addEventListener('nexus:stream-refresh', handler);
return () => window.removeEventListener('nexus:stream-refresh', handler);
}, []);

// ── Clear unread badge when a chat is opened ──
useEffect(() => {
if (selectedContactId) {
Expand Down
Loading