feat: real-time messaging, Electron desktop app, admin drive portal

Messaging:
- Optimistic UI: message appears instantly in sender's view on send
- Skip WS relay-back of own messages to avoid duplicates
- Fix sent_at field name mismatch (sentAt → sent_at fallback)

Admin portal:
- /admin route: list all users, storage used vs quota, file count
- Inline-edit per-user storage quota (MB) and admin flag
- Stats header: total users, total storage, total files
- is_admin + storage_quota_mb columns added to users table
- Login response now includes isAdmin; sidebar shows Admin link for admins
- requireAdmin middleware on all /admin/* routes

Electron desktop app (desktop/):
- BrowserWindow with native title bar, system tray, hide-to-tray on close
- First-run setup dialog to configure self-hosted server URL
- Persists server URL via electron-store
- Tray menu: Open, Change server URL, Quit
- IPC: showNotification for future native push notifications
- electron-builder config for macOS (DMG), Windows (NSIS), Linux (AppImage)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-28 10:31:41 -04:00
1 parent 46f0a4b71e
commit 9b57dd9b94
15 files changed
+6104 -14

No files matched your search

+2
View File
@@ -15,6 +15,8 @@ CREATE TABLE IF NOT EXISTS users (
totp_enabled BOOLEAN NOT NULL DEFAULT false,
-- Backup codes for 2FA (hashed)
totp_backup_codes TEXT[] DEFAULT '{}',
is_admin BOOLEAN NOT NULL DEFAULT false,
storage_quota_mb INTEGER NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ
);
+2
View File
@@ -11,6 +11,7 @@ import authRouter from './routes/auth.js';
import messagesRouter from './routes/messages.js';
import filesRouter from './routes/files.js';
import usersRouter from './routes/users.js';
import adminRouter from './routes/admin.js';
const app = express();
@@ -29,6 +30,7 @@ app.use('/api/auth', authLimiter, authRouter);
app.use('/api', messagesRouter);
app.use('/api', filesRouter);
app.use('/api', usersRouter);
app.use('/api', adminRouter);
// WebSocket: real-time messaging relay
// Server never sees plaintext — it only routes encrypted payloads
+70
View File
@@ -0,0 +1,70 @@
import { Router, Request, Response, NextFunction } from 'express';
import { db } from '../lib/db.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
async function requireAdmin(req: Request, res: Response, next: NextFunction) {
const userId = (req as any).userId;
const result = await db.query('SELECT is_admin FROM users WHERE id = $1', [userId]);
if (!result.rows[0]?.is_admin) return res.status(403).json({ error: 'Admin only' });
next();
}
// GET /admin/users — list all users with storage usage and quota
router.get('/admin/users', requireAuth, requireAdmin, async (req, res) => {
const result = await db.query(`
SELECT
u.id, u.username, u.email, u.is_admin, u.storage_quota_mb,
u.created_at, u.last_seen_at,
COALESCE(SUM(f.size_bytes) FILTER (WHERE f.deleted_at IS NULL), 0)::BIGINT AS storage_used_bytes,
COUNT(f.id) FILTER (WHERE f.deleted_at IS NULL) AS file_count
FROM users u
LEFT JOIN files f ON f.owner_id = u.id
GROUP BY u.id
ORDER BY u.created_at ASC
`);
res.json(result.rows);
});
// PATCH /admin/users/:id — update quota or admin status
router.patch('/admin/users/:id', requireAuth, requireAdmin, async (req, res) => {
const { id } = req.params;
const { storage_quota_mb, is_admin } = req.body;
const updates: string[] = [];
const values: any[] = [];
if (storage_quota_mb !== undefined) {
values.push(Number(storage_quota_mb));
updates.push(`storage_quota_mb = $${values.length}`);
}
if (is_admin !== undefined) {
values.push(Boolean(is_admin));
updates.push(`is_admin = $${values.length}`);
}
if (!updates.length) return res.status(400).json({ error: 'Nothing to update' });
values.push(id);
const result = await db.query(
`UPDATE users SET ${updates.join(', ')} WHERE id = $${values.length}
RETURNING id, username, is_admin, storage_quota_mb`,
values
);
if (!result.rows.length) return res.status(404).json({ error: 'User not found' });
res.json(result.rows[0]);
});
// GET /admin/stats — overall storage and user counts
router.get('/admin/stats', requireAuth, requireAdmin, async (req, res) => {
const result = await db.query(`
SELECT
COUNT(u.id) AS total_users,
COALESCE(SUM(f.size_bytes) FILTER (WHERE f.deleted_at IS NULL), 0)::BIGINT AS total_storage_bytes,
COUNT(f.id) FILTER (WHERE f.deleted_at IS NULL) AS total_files
FROM users u
LEFT JOIN files f ON f.owner_id = u.id
`);
res.json(result.rows[0]);
});
export default router;
+2 -1
View File
@@ -83,7 +83,7 @@ router.post('/login', async (req, res) => {
const { email, password, totpCode } = parsed.data;
const result = await db.query(
`SELECT id, username, password_hash, totp_enabled, totp_secret, kdf_salt, encrypted_master_key
`SELECT id, username, password_hash, totp_enabled, totp_secret, kdf_salt, encrypted_master_key, is_admin
FROM users WHERE email = $1`,
[email]
);
@@ -109,6 +109,7 @@ router.post('/login', async (req, res) => {
username: user.username,
kdfSalt: user.kdf_salt,
encryptedMasterKey: user.encrypted_master_key,
isAdmin: user.is_admin,
});
});
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" rx="100" fill="#1e1b4b"/>
<path d="M256 120 C180 120 120 180 120 256 C120 332 180 392 256 392 C332 392 392 332 392 256 C392 180 332 120 256 120Z" fill="none" stroke="#6366f1" stroke-width="24"/>
<path d="M200 200 L256 160 L312 200 L312 280 L256 320 L200 280Z" fill="#6366f1" opacity="0.9"/>
<circle cx="256" cy="240" r="30" fill="#e0e7ff"/>
<path d="M180 320 Q256 360 332 320" fill="none" stroke="#a5b4fc" stroke-width="16" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 585 B

+197
View File
@@ -0,0 +1,197 @@
'use strict';
const { app, BrowserWindow, Tray, Menu, ipcMain, Notification, nativeImage, shell } = require('electron');
const path = require('path');
// electron-store v10 uses ESM — load dynamically
let Store;
let store;
async function loadStore() {
const mod = await import('electron-store');
Store = mod.default;
store = new Store({
defaults: { serverUrl: '' },
});
}
let mainWindow = null;
let tray = null;
let setupWindow = null;
// ── Helpers ──────────────────────────────────────────────────────────────────
function getIconPath() {
if (process.platform === 'darwin') return path.join(__dirname, 'assets', 'icon.icns');
if (process.platform === 'win32') return path.join(__dirname, 'assets', 'icon.ico');
return path.join(__dirname, 'assets', 'icon.png');
}
function createTray() {
const trayImg = nativeImage.createFromPath(
path.join(__dirname, 'assets', 'tray.png')
);
tray = new Tray(trayImg.isEmpty() ? nativeImage.createEmpty() : trayImg);
tray.setToolTip('CloudLink');
const menu = Menu.buildFromTemplate([
{ label: 'Open CloudLink', click: () => { mainWindow?.show(); mainWindow?.focus(); } },
{ type: 'separator' },
{
label: 'Change server URL…',
click: () => openSetup(true),
},
{ type: 'separator' },
{ label: 'Quit', click: () => app.quit() },
]);
tray.setContextMenu(menu);
tray.on('click', () => { mainWindow?.show(); mainWindow?.focus(); });
}
function openSetup(fromTray = false) {
if (setupWindow) { setupWindow.focus(); return; }
setupWindow = new BrowserWindow({
width: 520,
height: 340,
resizable: false,
title: 'CloudLink — Setup',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
const current = store.get('serverUrl');
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f172a; color: #e2e8f0; display: flex; flex-direction: column;
align-items: center; justify-content: center; height: 100vh; gap: 24px; padding: 32px; }
h1 { font-size: 1.25rem; font-weight: 700; }
p { font-size: 0.875rem; color: #94a3b8; text-align: center; }
input { width: 100%; padding: 10px 14px; border-radius: 8px; border: 1px solid #334155;
background: #1e293b; color: #e2e8f0; font-size: 0.9rem; outline: none; }
input:focus { border-color: #6366f1; }
button { padding: 10px 24px; background: #6366f1; color: white; border: none;
border-radius: 8px; font-size: 0.9rem; cursor: pointer; }
button:hover { background: #4f46e5; }
.row { display: flex; gap: 12px; }
</style>
</head>
<body>
<h1>🔗 CloudLink</h1>
<p>Enter the URL of your CloudLink server.<br>
For local development use <code style="color:#818cf8">http://localhost:5173</code></p>
<input id="url" type="url" placeholder="https://cloudlink.yourserver.com"
value="${current}" />
<div class="row">
<button onclick="save()">Connect</button>
</div>
<script>
function save() {
const v = document.getElementById('url').value.trim().replace(/\\/$/, '');
if (!v) return;
window.cloudlink.setServerUrl(v);
}
</script>
</body>
</html>`;
setupWindow.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html));
setupWindow.on('closed', () => { setupWindow = null; });
}
function createMainWindow(serverUrl) {
mainWindow = new BrowserWindow({
width: 1280,
height: 820,
minWidth: 900,
minHeight: 600,
title: 'CloudLink',
icon: getIconPath(),
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
// Allow the web app to use the Web Crypto API and WebSockets
webSecurity: true,
},
});
mainWindow.loadURL(serverUrl);
// Open external links in the default browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
// Hide to tray instead of quitting on close (macOS/Windows convention)
mainWindow.on('close', (e) => {
if (process.platform !== 'darwin' && !app.isQuitting) {
e.preventDefault();
mainWindow.hide();
}
});
mainWindow.on('closed', () => { mainWindow = null; });
}
// ── IPC ───────────────────────────────────────────────────────────────────────
ipcMain.handle('get-server-url', () => store.get('serverUrl'));
ipcMain.handle('set-server-url', (_e, url) => {
store.set('serverUrl', url);
setupWindow?.close();
if (mainWindow) {
mainWindow.loadURL(url);
} else {
createMainWindow(url);
}
});
ipcMain.handle('show-notification', (_e, { title, body }) => {
if (Notification.isSupported()) {
new Notification({ title, body }).show();
}
});
// ── App lifecycle ─────────────────────────────────────────────────────────────
app.whenReady().then(async () => {
await loadStore();
createTray();
const serverUrl = store.get('serverUrl');
if (!serverUrl) {
openSetup();
} else {
createMainWindow(serverUrl);
}
app.on('activate', () => {
if (!mainWindow && store.get('serverUrl')) {
createMainWindow(store.get('serverUrl'));
} else {
mainWindow?.show();
}
});
});
app.on('before-quit', () => { app.isQuitting = true; });
// Keep running when all windows are closed (show via tray)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
// don't quit — user can reopen from tray
}
});
+5541
View File
File diff suppressed because it is too large. Load diff
+51
View File
@@ -0,0 +1,51 @@
{
"name": "cloudlink-desktop",
"version": "1.0.0",
"description": "CloudLink — encrypted messaging and cloud drive",
"main": "main.js",
"scripts": {
"start": "electron .",
"build": "electron-builder --mac --win --linux",
"build:mac": "electron-builder --mac",
"build:win": "electron-builder --win",
"build:linux": "electron-builder --linux"
},
"build": {
"appId": "us.necloud.cloudlink",
"productName": "CloudLink",
"copyright": "Copyright © 2026",
"directories": {
"output": "dist"
},
"files": [
"main.js",
"preload.js",
"assets/**"
],
"mac": {
"category": "public.app-category.productivity",
"icon": "assets/icon.icns",
"target": [{ "target": "dmg", "arch": ["x64", "arm64"] }]
},
"win": {
"icon": "assets/icon.ico",
"target": [{ "target": "nsis", "arch": ["x64"] }]
},
"linux": {
"icon": "assets/icon.png",
"category": "Network",
"target": [{ "target": "AppImage", "arch": ["x64"] }]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true
}
},
"devDependencies": {
"electron": "^33.0.0",
"electron-builder": "^25.0.0"
},
"dependencies": {
"electron-store": "^10.0.0"
}
}
+13
View File
@@ -0,0 +1,13 @@
'use strict';
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('cloudlink', {
getServerUrl: () => ipcRenderer.invoke('get-server-url'),
setServerUrl: (url) => ipcRenderer.invoke('set-server-url', url),
showNotification: (title, body) =>
ipcRenderer.invoke('show-notification', { title, body }),
// Platform detection for the web app
platform: process.platform,
isDesktop: true,
});
+2
View File
@@ -6,6 +6,7 @@ import Layout from './components/Layout';
import MessagesPage from './pages/MessagesPage';
import DrivePage from './pages/DrivePage';
import SettingsPage from './pages/SettingsPage';
import AdminPage from './pages/AdminPage';
function RequireAuth({ children }: { children: React.ReactNode }) {
const { accessToken, masterKey } = useAuthStore();
@@ -32,6 +33,7 @@ export default function App() {
<Route path="messages/:id" element={<MessagesPage />} />
<Route path="drive" element={<DrivePage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="admin" element={<AdminPage />} />
</Route>
</Routes>
);
+4 -3
View File
@@ -1,16 +1,17 @@
import { Outlet, NavLink } from 'react-router-dom';
import { MessageSquare, HardDrive, Settings, LogOut, Shield } from 'lucide-react';
import { MessageSquare, HardDrive, Settings, LogOut, Shield, ShieldCheck } from 'lucide-react';
import { useAuthStore } from '../lib/store';
import { socket } from '../lib/ws';
const nav = [
const baseNav = [
{ to: '/messages', icon: MessageSquare, label: 'Messages' },
{ to: '/drive', icon: HardDrive, label: 'Drive' },
{ to: '/settings', icon: Settings, label: 'Settings' },
];
export default function Layout() {
const { logout, username } = useAuthStore();
const { logout, username, isAdmin } = useAuthStore();
const nav = isAdmin ? [...baseNav, { to: '/admin', icon: ShieldCheck, label: 'Admin' }] : baseNav;
function handleLogout() {
socket.disconnect();
+6 -3
View File
@@ -6,11 +6,12 @@ interface AuthState {
refreshToken: string | null;
userId: string | null;
username: string | null;
isAdmin: boolean;
// Crypto keys — in-memory only, never persisted
masterKey: CryptoKey | null;
privateKey: CryptoKey | null; // X25519 private key (non-extractable CryptoKey)
publicKey: string | null; // X25519 public key (base64)
setAuth: (data: { accessToken: string; refreshToken: string; userId: string }) => void;
setAuth: (data: { accessToken: string; refreshToken: string; userId: string; isAdmin?: boolean }) => void;
setTokens: (access: string, refresh: string) => void;
setMasterKey: (key: CryptoKey) => void;
setKeyPair: (publicKeyB64: string, privateKey: CryptoKey) => void;
@@ -25,11 +26,12 @@ export const useAuthStore = create<AuthState>()(
refreshToken: null,
userId: null,
username: null,
isAdmin: false,
masterKey: null,
privateKey: null as CryptoKey | null,
publicKey: null,
setAuth: ({ accessToken, refreshToken, userId }) =>
set({ accessToken, refreshToken, userId }),
setAuth: ({ accessToken, refreshToken, userId, isAdmin }) =>
set({ accessToken, refreshToken, userId, isAdmin: isAdmin ?? false }),
setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken }),
setMasterKey: (masterKey) => set({ masterKey }),
setKeyPair: (publicKey, privateKey) => set({ publicKey, privateKey }),
@@ -52,6 +54,7 @@ export const useAuthStore = create<AuthState>()(
refreshToken: s.refreshToken,
userId: s.userId,
username: s.username,
isAdmin: s.isAdmin,
}),
}
)
+186
View File
@@ -0,0 +1,186 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Shield, HardDrive, Users, Settings2 } from 'lucide-react';
import api from '../lib/api';
import { useAuthStore } from '../lib/store';
interface AdminUser {
id: string;
username: string;
email: string;
is_admin: boolean;
storage_quota_mb: number;
storage_used_bytes: number;
file_count: number;
created_at: string;
last_seen_at: string | null;
}
interface Stats {
total_users: number;
total_storage_bytes: number;
total_files: number;
}
function fmt(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
}
export default function AdminPage() {
const { userId } = useAuthStore();
const qc = useQueryClient();
const [editing, setEditing] = useState<Record<string, { quota: number; admin: boolean }>>({});
const { data: users = [], isLoading } = useQuery<AdminUser[]>({
queryKey: ['admin-users'],
queryFn: () => api.get('/admin/users').then((r) => r.data),
retry: false,
});
const { data: stats } = useQuery<Stats>({
queryKey: ['admin-stats'],
queryFn: () => api.get('/admin/stats').then((r) => r.data),
retry: false,
});
const patch = useMutation({
mutationFn: ({ id, body }: { id: string; body: object }) =>
api.patch(`/admin/users/${id}`, body).then((r) => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-users'] });
qc.invalidateQueries({ queryKey: ['admin-stats'] });
},
});
function startEdit(u: AdminUser) {
setEditing((prev) => ({
...prev,
[u.id]: { quota: u.storage_quota_mb, admin: u.is_admin },
}));
}
function saveEdit(id: string) {
const e = editing[id];
if (!e) return;
patch.mutate({ id, body: { storage_quota_mb: e.quota, is_admin: e.admin } });
setEditing((prev) => { const n = { ...prev }; delete n[id]; return n; });
}
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center text-slate-500">
Loading admin portal…
</div>
);
}
return (
<div className="flex-1 overflow-y-auto p-6 max-w-5xl mx-auto w-full">
<div className="flex items-center gap-3 mb-6">
<Shield className="w-6 h-6 text-accent" />
<h1 className="text-xl font-bold">Admin Portal</h1>
</div>
{/* Stats row */}
{stats && (
<div className="grid grid-cols-3 gap-4 mb-8">
{[
{ icon: Users, label: 'Total users', value: stats.total_users },
{ icon: HardDrive, label: 'Total storage', value: fmt(Number(stats.total_storage_bytes)) },
{ icon: Settings2, label: 'Total files', value: stats.total_files },
].map(({ icon: Icon, label, value }) => (
<div key={label} className="bg-surface-1 border border-slate-700 rounded-xl p-4 flex items-center gap-3">
<Icon className="w-5 h-5 text-accent shrink-0" />
<div>
<p className="text-xs text-slate-400">{label}</p>
<p className="text-lg font-bold">{value}</p>
</div>
</div>
))}
</div>
)}
{/* Users table */}
<div className="bg-surface-1 border border-slate-700 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="border-b border-slate-700 bg-surface-2">
<tr>
{['User', 'Storage used / quota', 'Files', 'Admin', 'Actions'].map((h) => (
<th key={h} className="text-left px-4 py-3 text-xs text-slate-400 font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{users.map((u) => {
const e = editing[u.id];
const usedMb = Number(u.storage_used_bytes) / 1024 / 1024;
const pct = Math.min(100, (usedMb / (e?.quota ?? u.storage_quota_mb)) * 100);
const isSelf = u.id === userId;
return (
<tr key={u.id} className="border-b border-slate-700/50 hover:bg-surface-2 transition-colors">
<td className="px-4 py-3">
<p className="font-medium">{u.username}{isSelf && <span className="ml-1 text-xs text-accent">(you)</span>}</p>
<p className="text-xs text-slate-500">{u.email}</p>
</td>
<td className="px-4 py-3 w-56">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs">{fmt(Number(u.storage_used_bytes))}</span>
<span className="text-xs text-slate-500">of</span>
{e ? (
<input
type="number"
min={1}
className="input w-20 text-xs py-0.5 px-2"
value={e.quota}
onChange={(ev) => setEditing((prev) => ({ ...prev, [u.id]: { ...prev[u.id], quota: Number(ev.target.value) } }))}
/>
) : (
<span className="text-xs">{u.storage_quota_mb} MB</span>
)}
</div>
<div className="h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-accent'}`}
style={{ width: `${pct}%` }}
/>
</div>
</td>
<td className="px-4 py-3 text-slate-400">{u.file_count}</td>
<td className="px-4 py-3">
{e ? (
<input
type="checkbox"
checked={e.admin}
onChange={(ev) => setEditing((prev) => ({ ...prev, [u.id]: { ...prev[u.id], admin: ev.target.checked } }))}
className="w-4 h-4 accent-indigo-500"
disabled={isSelf}
/>
) : (
<span className={`text-xs px-2 py-0.5 rounded-full ${u.is_admin ? 'bg-accent/20 text-accent' : 'bg-slate-700 text-slate-400'}`}>
{u.is_admin ? 'Admin' : 'User'}
</span>
)}
</td>
<td className="px-4 py-3">
{e ? (
<div className="flex gap-2">
<button onClick={() => saveEdit(u.id)} className="btn-primary text-xs py-1 px-3">Save</button>
<button onClick={() => setEditing((prev) => { const n = { ...prev }; delete n[u.id]; return n; })} className="text-xs text-slate-400 hover:text-white">Cancel</button>
</div>
) : (
<button onClick={() => startEdit(u)} className="text-xs text-accent hover:underline">Edit</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
+2 -2
View File
@@ -34,14 +34,14 @@ export default function LoginPage() {
return;
}
const { accessToken, refreshToken, userId, username, kdfSalt, encryptedMasterKey } = res.data;
const { accessToken, refreshToken, userId, username, kdfSalt, encryptedMasterKey, isAdmin } = res.data;
// Re-derive master key from password — never leaves the browser
const derived = await deriveKeyFromPassword(password, kdfSalt);
const masterKey = await decryptMasterKey(encryptedMasterKey, derived);
// Set tokens first so the next API call has a Bearer token
setAuth({ accessToken, refreshToken, userId });
setAuth({ accessToken, refreshToken, userId, isAdmin });
setMasterKey(masterKey);
setUsername(username);
+19 -5
View File
@@ -83,11 +83,12 @@ export default function MessagesPage() {
load();
}, [activeId, conversations.length]);
// WebSocket: decrypt incoming messages in real time
// WebSocket: decrypt incoming messages in real time (skip own — already shown optimistically)
useEffect(() => {
return socket.on(async (raw) => {
const msg = raw as any;
if (msg.type !== 'message' || msg.conversationId !== activeId) return;
if (msg.senderId === userId) return; // Own message already added optimistically
const convKey = getConvKey(activeId!);
if (!convKey) return;
@@ -95,10 +96,10 @@ export default function MessagesPage() {
const plaintext = await decryptMessage(msg.ciphertext, convKey).catch(() => '[encrypted]');
setMessages((prev) => [
...prev,
{ ...msg, sender_username: msg.senderUsername ?? msg.senderId, plaintext, sent_at: msg.sent_at ?? new Date().toISOString() },
{ ...msg, sender_username: msg.senderUsername ?? msg.senderId, plaintext, sent_at: msg.sentAt ?? new Date().toISOString() },
]);
});
}, [activeId]);
}, [activeId, userId]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -109,9 +110,22 @@ export default function MessagesPage() {
const convKey = getConvKey(activeId);
if (!convKey) return;
const ciphertext = await encryptMessage(text.trim(), convKey);
const plaintext = text.trim();
setText(''); // Clear immediately for responsiveness
const ciphertext = await encryptMessage(plaintext, convKey);
// Optimistic update — show in UI before server confirms
setMessages((prev) => [...prev, {
id: `pending-${Date.now()}`,
sender_id: userId!,
sender_username: useAuthStore.getState().username || userId!,
ciphertext,
plaintext,
sent_at: new Date().toISOString(),
}]);
socket.send({ type: 'message', conversationId: activeId, ciphertext });
setText('');
}
async function startConversation(recipient: UserResult) {