diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index bd4cce5..9146dd6 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -19,7 +19,11 @@
"WebFetch(domain:github.com)",
"WebFetch(domain:docs.gitea.com)",
"WebFetch(domain:www.electron.build)",
- "WebFetch(domain:forum.gitea.com)"
+ "WebFetch(domain:forum.gitea.com)",
+ "WebFetch(domain:www.convex.dev)",
+ "WebFetch(domain:www.npmjs.com)",
+ "WebFetch(domain:stack.convex.dev)",
+ "WebFetch(domain:raw.githubusercontent.com)"
]
}
}
diff --git a/Frontend/Electron/main.cjs b/Frontend/Electron/main.cjs
index 2457374..4ed46bb 100644
--- a/Frontend/Electron/main.cjs
+++ b/Frontend/Electron/main.cjs
@@ -1,13 +1,60 @@
-const { app, BrowserWindow, ipcMain, shell } = require('electron');
+const { app, BrowserWindow, ipcMain, shell, screen, safeStorage } = require('electron');
const path = require('path');
+const fs = require('fs');
+
+// --- Secure session persistence ---
+const SESSION_FILE = path.join(app.getPath('userData'), 'secure-session.dat');
const https = require('https');
const http = require('http');
const { checkForUpdates } = require('./updater.cjs');
+// --- Settings persistence ---
+const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json');
+const DEFAULT_SETTINGS = {
+ windowX: undefined,
+ windowY: undefined,
+ windowWidth: 1200,
+ windowHeight: 800,
+ isMaximized: false,
+ theme: 'theme-dark',
+};
+
+let mainWindow = null;
+
+function loadSettings() {
+ try {
+ const data = fs.readFileSync(SETTINGS_FILE, 'utf8');
+ return { ...DEFAULT_SETTINGS, ...JSON.parse(data) };
+ } catch {
+ return { ...DEFAULT_SETTINGS };
+ }
+}
+
+function saveSettings(settings) {
+ try {
+ fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), 'utf8');
+ } catch (err) {
+ console.error('Failed to save settings:', err.message);
+ }
+}
+
+function isPositionOnScreen(x, y, w, h) {
+ const displays = screen.getAllDisplays();
+ const MIN_OVERLAP = 100;
+ return displays.some(display => {
+ const { x: dx, y: dy, width: dw, height: dh } = display.bounds;
+ const overlapX = Math.max(0, Math.min(x + w, dx + dw) - Math.max(x, dx));
+ const overlapY = Math.max(0, Math.min(y + h, dy + dh) - Math.max(y, dy));
+ return overlapX >= MIN_OVERLAP && overlapY >= MIN_OVERLAP;
+ });
+}
+
function createWindow() {
- const win = new BrowserWindow({
- width: 1200,
- height: 800,
+ const settings = loadSettings();
+
+ const windowOptions = {
+ width: settings.windowWidth,
+ height: settings.windowHeight,
frame: false,
webPreferences: {
nodeIntegration: false,
@@ -15,17 +62,44 @@ function createWindow() {
preload: path.join(__dirname, 'preload.cjs'),
sandbox: false
}
+ };
+
+ // Only restore position if it's valid on a connected display
+ if (settings.windowX !== undefined && settings.windowY !== undefined &&
+ isPositionOnScreen(settings.windowX, settings.windowY, settings.windowWidth, settings.windowHeight)) {
+ windowOptions.x = settings.windowX;
+ windowOptions.y = settings.windowY;
+ }
+
+ mainWindow = new BrowserWindow(windowOptions);
+
+ if (settings.isMaximized) {
+ mainWindow.maximize();
+ }
+
+ // Save window state on close
+ mainWindow.on('close', () => {
+ const current = loadSettings(); // re-read to preserve theme changes
+ if (!mainWindow.isMaximized()) {
+ const bounds = mainWindow.getBounds();
+ current.windowX = bounds.x;
+ current.windowY = bounds.y;
+ current.windowWidth = bounds.width;
+ current.windowHeight = bounds.height;
+ }
+ current.isMaximized = mainWindow.isMaximized();
+ saveSettings(current);
});
const isDev = process.env.npm_lifecycle_event === 'electron:dev';
if (isDev) {
- win.loadURL('http://localhost:5173');
- win.webContents.openDevTools();
+ mainWindow.loadURL('http://localhost:5173');
+ mainWindow.webContents.openDevTools();
} else {
// Production: Load the built file
// dist-react is in the same directory as main.cjs
- win.loadFile(path.join(__dirname, 'dist-react', 'index.html'));
+ mainWindow.loadFile(path.join(__dirname, 'dist-react', 'index.html'));
}
}
@@ -77,75 +151,254 @@ app.whenReady().then(async () => {
});
// Helper to fetch metadata (Zero-Knowledge: Client fetches previews)
+
+ const OEMBED_PROVIDERS = {
+ 'twitter.com': (url) => url.includes('/status/') ? `https://publish.twitter.com/oembed?url=${encodeURIComponent(url)}&format=json` : null,
+ 'x.com': (url) => url.includes('/status/') ? `https://publish.twitter.com/oembed?url=${encodeURIComponent(url)}&format=json` : null,
+ 'youtube.com': (url) => `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,
+ 'www.youtube.com': (url) => `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,
+ 'youtu.be': (url) => `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`,
+ 'open.spotify.com': (url) => `https://open.spotify.com/oembed?url=${encodeURIComponent(url)}`,
+ 'www.tiktok.com': (url) => `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`,
+ 'tiktok.com': (url) => `https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`,
+ 'www.reddit.com': (url) => `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}&format=json`,
+ 'reddit.com': (url) => `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}&format=json`,
+ };
+
+ const FETCH_HEADERS = {
+ 'User-Agent': 'Mozilla/5.0 (compatible; DiscordBot/1.0)',
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.5',
+ };
+
+ const MAX_RESPONSE_SIZE = 256 * 1024; // 256KB
+ const FETCH_TIMEOUT = 8000;
+ const MAX_REDIRECTS = 5;
+
+ function httpGet(url, redirectsLeft = MAX_REDIRECTS) {
+ return new Promise((resolve, reject) => {
+ const client = url.startsWith('https') ? https : http;
+ const req = client.get(url, { headers: FETCH_HEADERS, timeout: FETCH_TIMEOUT }, (res) => {
+ if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
+ if (redirectsLeft <= 0) { resolve(''); return; }
+ let redirectUrl = res.headers.location;
+ if (redirectUrl.startsWith('/')) {
+ const parsed = new URL(url);
+ redirectUrl = parsed.origin + redirectUrl;
+ }
+ res.resume();
+ httpGet(redirectUrl, redirectsLeft - 1).then(resolve).catch(reject);
+ return;
+ }
+ let data = '';
+ let size = 0;
+ res.setEncoding('utf8');
+ res.on('data', (chunk) => {
+ size += Buffer.byteLength(chunk);
+ if (size > MAX_RESPONSE_SIZE) { res.destroy(); resolve(data); return; }
+ data += chunk;
+ });
+ res.on('end', () => resolve(data));
+ res.on('error', () => resolve(data));
+ });
+ req.on('timeout', () => { req.destroy(); resolve(''); });
+ req.on('error', (err) => { console.error('httpGet error:', err.message); resolve(''); });
+ });
+ }
+
+ function fetchJson(url, redirectsLeft = MAX_REDIRECTS) {
+ return new Promise((resolve, reject) => {
+ const client = url.startsWith('https') ? https : http;
+ const req = client.get(url, { headers: { 'User-Agent': FETCH_HEADERS['User-Agent'], 'Accept': 'application/json' }, timeout: FETCH_TIMEOUT }, (res) => {
+ if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
+ if (redirectsLeft <= 0) { resolve(null); return; }
+ let redirectUrl = res.headers.location;
+ if (redirectUrl.startsWith('/')) {
+ const parsed = new URL(url);
+ redirectUrl = parsed.origin + redirectUrl;
+ }
+ res.resume();
+ fetchJson(redirectUrl, redirectsLeft - 1).then(resolve).catch(reject);
+ return;
+ }
+ let data = '';
+ res.setEncoding('utf8');
+ res.on('data', (chunk) => { data += chunk; });
+ res.on('end', () => { try { resolve(JSON.parse(data)); } catch { resolve(null); } });
+ res.on('error', () => resolve(null));
+ });
+ req.on('timeout', () => { req.destroy(); resolve(null); });
+ req.on('error', () => resolve(null));
+ });
+ }
+
+ function parseMetaTags(html) {
+ const meta = {};
+ // Match both orderings: property/name before content AND content before property/name
+ const metaRegex = /]*?)\/?\s*>/gi;
+ let match;
+ while ((match = metaRegex.exec(html)) !== null) {
+ const attrs = match[1];
+ let name = null, content = null;
+ // Extract property or name attribute
+ const propMatch = attrs.match(/(?:property|name)\s*=\s*["']([^"']+)["']/i);
+ const contentMatch = attrs.match(/content\s*=\s*["']([^"']*?)["']/i);
+ if (propMatch) name = propMatch[1].toLowerCase();
+ if (contentMatch) content = contentMatch[1];
+ if (name && content !== null) meta[name] = content;
+ }
+ // Extract
tag
+ const titleMatch = html.match(/]*>([\s\S]*?)<\/title>/i);
+ if (titleMatch) meta['_title'] = titleMatch[1].trim();
+ return meta;
+ }
+
+ function buildMetadata(meta) {
+ const get = (...keys) => { for (const k of keys) { if (meta[k]) return meta[k]; } return null; };
+ return {
+ title: get('og:title', 'twitter:title', '_title'),
+ description: get('og:description', 'twitter:description', 'description'),
+ image: get('og:image', 'og:image:secure_url', 'twitter:image', 'twitter:image:src'),
+ siteName: get('og:site_name'),
+ themeColor: get('theme-color'),
+ video: get('og:video:secure_url', 'og:video:url', 'og:video'),
+ type: get('og:type', 'twitter:card'),
+ author: get('article:author', 'author'),
+ };
+ }
+
+ function sanitizeMetadata(metadata) {
+ const decodeEntities = (str) => {
+ if (!str) return str;
+ return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'")
+ .replace(/(\d+);/g, (_, n) => String.fromCharCode(n))
+ .replace(/([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)));
+ };
+ const stripTags = (str) => str ? str.replace(/<[^>]*>/g, '') : str;
+ const limit = (str, max = 1000) => str && str.length > max ? str.substring(0, max) : str;
+
+ const result = {};
+ for (const [key, value] of Object.entries(metadata)) {
+ if (typeof value === 'string') {
+ result[key] = limit(decodeEntities(stripTags(value)).trim());
+ } else {
+ result[key] = value;
+ }
+ }
+ return result;
+ }
+
ipcMain.handle('fetch-metadata', async (event, url) => {
- return new Promise((resolve) => {
- // Check for direct video links to avoid downloading large files
+ try {
+ // Check for direct video links
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov'];
const imageExtensions = ['.gif', '.png', '.jpg', '.jpeg', '.webp'];
const lowerUrl = url.toLowerCase();
-
+
if (videoExtensions.some(ext => lowerUrl.endsWith(ext))) {
- const filename = url.split('/').pop();
- resolve({
- title: filename,
- siteName: new URL(url).hostname,
- video: url,
- image: null, // No thumbnail for now unless we generate one
- description: 'Video File'
- });
- return;
+ return { title: url.split('/').pop(), siteName: new URL(url).hostname, video: url, image: null, description: 'Video File' };
}
-
if (imageExtensions.some(ext => lowerUrl.endsWith(ext))) {
- const filename = url.split('/').pop();
- resolve({
- title: filename,
- siteName: new URL(url).hostname,
- video: null,
- image: url, // Direct image/gif
- description: 'Image File'
- });
- return;
+ return { title: url.split('/').pop(), siteName: new URL(url).hostname, video: null, image: url, description: 'Image File' };
}
- const client = url.startsWith('https') ? https : http;
- const req = client.get(url, (res) => {
- let data = '';
- res.on('data', (chunk) => data += chunk);
- res.on('end', () => {
- // Simple Regex Parser for OG Tags to avoid dependencies
- const getMeta = (prop) => {
- const regex = new RegExp(` {
- const regex = /(.*?)<\/title>/i;
- const match = data.match(regex);
- return match ? match[1] : null;
- };
+ const hostname = new URL(url).hostname.replace(/^www\./, '');
+ const oembedBuilder = OEMBED_PROVIDERS[hostname] || OEMBED_PROVIDERS['www.' + hostname];
+ const oembedUrl = oembedBuilder ? oembedBuilder(url) : null;
- const metadata = {
- title: getMeta('title') || getTitle(),
- description: getMeta('description'),
- image: getMeta('image'),
- siteName: getMeta('site_name'),
- themeColor: getMeta('theme-color')
- };
- resolve(metadata);
- });
- });
- req.on('error', (err) => {
- console.error('Metadata fetch error:', err);
- resolve(null);
- });
- });
+ let metadata;
+
+ if (oembedUrl) {
+ // Fetch oEmbed JSON and page HTML in parallel
+ const [oembedResult, htmlResult] = await Promise.allSettled([
+ fetchJson(oembedUrl),
+ httpGet(url),
+ ]);
+
+ const oembed = oembedResult.status === 'fulfilled' ? oembedResult.value : null;
+ const html = htmlResult.status === 'fulfilled' ? htmlResult.value : '';
+ const meta = html ? parseMetaTags(html) : {};
+ const ogData = buildMetadata(meta);
+
+ metadata = {
+ title: oembed?.title || ogData.title,
+ description: ogData.description,
+ image: oembed?.thumbnail_url || ogData.image,
+ siteName: oembed?.provider_name || ogData.siteName,
+ themeColor: ogData.themeColor,
+ video: ogData.video,
+ type: ogData.type,
+ author: oembed?.author_name || ogData.author,
+ };
+ } else {
+ // Standard HTML fetch
+ const html = await httpGet(url);
+ if (!html) return null;
+ const meta = parseMetaTags(html);
+ metadata = buildMetadata(meta);
+ }
+
+ return sanitizeMetadata(metadata);
+ } catch (err) {
+ console.error('Metadata fetch error:', err);
+ return null;
+ }
});
ipcMain.handle('open-external', async (event, url) => {
await shell.openExternal(url);
});
+ // Settings IPC handlers
+ ipcMain.handle('get-setting', (event, key) => {
+ const settings = loadSettings();
+ return settings[key];
+ });
+
+ ipcMain.handle('set-setting', (event, key, value) => {
+ const settings = loadSettings();
+ settings[key] = value;
+ saveSettings(settings);
+ });
+
+ // Secure session persistence handlers
+ ipcMain.handle('save-session', (event, data) => {
+ try {
+ if (!safeStorage.isEncryptionAvailable()) return false;
+ const encrypted = safeStorage.encryptString(JSON.stringify(data));
+ fs.writeFileSync(SESSION_FILE, encrypted);
+ return true;
+ } catch (err) {
+ console.error('Failed to save session:', err.message);
+ return false;
+ }
+ });
+
+ ipcMain.handle('load-session', () => {
+ try {
+ if (!safeStorage.isEncryptionAvailable()) return null;
+ if (!fs.existsSync(SESSION_FILE)) return null;
+ const encrypted = fs.readFileSync(SESSION_FILE);
+ const decrypted = safeStorage.decryptString(encrypted);
+ return JSON.parse(decrypted);
+ } catch (err) {
+ console.error('Failed to load session (clearing corrupt file):', err.message);
+ try { fs.unlinkSync(SESSION_FILE); } catch {}
+ return null;
+ }
+ });
+
+ ipcMain.handle('clear-session', () => {
+ try {
+ if (fs.existsSync(SESSION_FILE)) fs.unlinkSync(SESSION_FILE);
+ return true;
+ } catch (err) {
+ console.error('Failed to clear session:', err.message);
+ return false;
+ }
+ });
+
ipcMain.handle('get-screen-sources', async () => {
const { desktopCapturer } = require('electron');
const sources = await desktopCapturer.getSources({
diff --git a/Frontend/Electron/package-lock.json b/Frontend/Electron/package-lock.json
index ee8169a..7865304 100644
--- a/Frontend/Electron/package-lock.json
+++ b/Frontend/Electron/package-lock.json
@@ -1,13 +1,14 @@
{
"name": "discord",
- "version": "0.0.0",
+ "version": "1.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "discord",
- "version": "0.0.0",
+ "version": "1.0.2",
"dependencies": {
+ "@convex-dev/presence": "^0.3.0",
"@livekit/components-react": "^2.9.17",
"@livekit/components-styles": "^1.2.0",
"convex": "^1.31.2",
@@ -16,6 +17,7 @@
"livekit-client": "^2.16.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+ "react-easy-crop": "^5.5.6",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.11.0",
"react-syntax-highlighter": "^16.1.0",
@@ -334,6 +336,27 @@
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
+ "node_modules/@convex-dev/presence": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@convex-dev/presence/-/presence-0.3.0.tgz",
+ "integrity": "sha512-adV+ao1L77u+egobyJabNwuai/0y/VgzMbqZiy+Q49JmX6fbSaiYg6FpFVACqPaq3giOfjrN2k+5mK2jTAUG0g==",
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "convex": "^1.24.8",
+ "expo-crypto": ">=14.1.0",
+ "react": "~18.3.1 || ^19.0.0",
+ "react-dom": "~18.3.1 || ^19.0.0",
+ "react-native": ">=0.79.0"
+ },
+ "peerDependenciesMeta": {
+ "expo-crypto": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@develar/schema-utils": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
@@ -8176,6 +8199,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/normalize-wheel": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
+ "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/npmlog": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz",
@@ -8670,6 +8699,20 @@
"react": "^19.2.3"
}
},
+ "node_modules/react-easy-crop": {
+ "version": "5.5.6",
+ "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.6.tgz",
+ "integrity": "sha512-Jw3/ozs8uXj3NpL511Suc4AHY+mLRO23rUgipXvNYKqezcFSYHxe4QXibBymkOoY6oOtLVMPO2HNPRHYvMPyTw==",
+ "license": "MIT",
+ "dependencies": {
+ "normalize-wheel": "^1.0.1",
+ "tslib": "^2.0.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.4.0",
+ "react-dom": ">=16.4.0"
+ }
+ },
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
diff --git a/Frontend/Electron/package.json b/Frontend/Electron/package.json
index 70863e0..ee91791 100644
--- a/Frontend/Electron/package.json
+++ b/Frontend/Electron/package.json
@@ -34,14 +34,21 @@
}
],
"win": {
- "target": ["nsis"]
+ "target": [
+ "nsis"
+ ]
},
"mac": {
- "target": ["dmg", "zip"],
+ "target": [
+ "dmg",
+ "zip"
+ ],
"category": "public.app-category.social-networking"
},
"linux": {
- "target": ["AppImage"]
+ "target": [
+ "AppImage"
+ ]
},
"nsis": {
"oneClick": true,
@@ -49,6 +56,7 @@
}
},
"dependencies": {
+ "@convex-dev/presence": "^0.3.0",
"@livekit/components-react": "^2.9.17",
"@livekit/components-styles": "^1.2.0",
"convex": "^1.31.2",
@@ -57,6 +65,7 @@
"livekit-client": "^2.16.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+ "react-easy-crop": "^5.5.6",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.11.0",
"react-syntax-highlighter": "^16.1.0",
diff --git a/Frontend/Electron/preload.cjs b/Frontend/Electron/preload.cjs
index ebf0750..26b804e 100644
--- a/Frontend/Electron/preload.cjs
+++ b/Frontend/Electron/preload.cjs
@@ -24,3 +24,14 @@ contextBridge.exposeInMainWorld('windowControls', {
maximize: () => ipcRenderer.send('window-maximize'),
close: () => ipcRenderer.send('window-close'),
});
+
+contextBridge.exposeInMainWorld('appSettings', {
+ get: (key) => ipcRenderer.invoke('get-setting', key),
+ set: (key, value) => ipcRenderer.invoke('set-setting', key, value),
+});
+
+contextBridge.exposeInMainWorld('sessionPersistence', {
+ save: (data) => ipcRenderer.invoke('save-session', data),
+ load: () => ipcRenderer.invoke('load-session'),
+ clear: () => ipcRenderer.invoke('clear-session'),
+});
diff --git a/Frontend/Electron/src/App.jsx b/Frontend/Electron/src/App.jsx
index b19bb10..2f21cf0 100644
--- a/Frontend/Electron/src/App.jsx
+++ b/Frontend/Electron/src/App.jsx
@@ -1,16 +1,100 @@
-import React from 'react';
-import { Routes, Route } from 'react-router-dom';
+import React, { useState, useEffect, useRef } from 'react';
+import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import Login from './pages/Login';
import Register from './pages/Register';
import Chat from './pages/Chat';
+const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
+
+function AuthGuard({ children }) {
+ const [authState, setAuthState] = useState('loading'); // 'loading' | 'authenticated' | 'unauthenticated'
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function restoreSession() {
+ // Already have keys in sessionStorage — current session is active
+ if (sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey')) {
+ if (!cancelled) setAuthState('authenticated');
+ return;
+ }
+
+ // Try restoring from safeStorage
+ if (window.sessionPersistence) {
+ try {
+ const session = await window.sessionPersistence.load();
+ if (session && session.savedAt && (Date.now() - session.savedAt) < THIRTY_DAYS_MS) {
+ // Restore to localStorage + sessionStorage
+ localStorage.setItem('userId', session.userId);
+ localStorage.setItem('username', session.username);
+ if (session.publicKey) localStorage.setItem('publicKey', session.publicKey);
+ sessionStorage.setItem('signingKey', session.signingKey);
+ sessionStorage.setItem('privateKey', session.privateKey);
+ if (!cancelled) setAuthState('authenticated');
+ return;
+ }
+ // Expired — clear stale session
+ if (session && session.savedAt) {
+ await window.sessionPersistence.clear();
+ }
+ } catch (err) {
+ console.error('Session restore failed:', err);
+ }
+ }
+
+ if (!cancelled) setAuthState('unauthenticated');
+ }
+
+ restoreSession();
+ return () => { cancelled = true; };
+ }, []);
+
+ // Redirect once after auth state is determined (not on every route change)
+ const hasRedirected = useRef(false);
+
+ useEffect(() => {
+ if (authState === 'loading' || hasRedirected.current) return;
+ hasRedirected.current = true;
+
+ const isAuthPage = location.pathname === '/' || location.pathname === '/register';
+
+ if (authState === 'authenticated' && isAuthPage) {
+ navigate('/chat', { replace: true });
+ } else if (authState === 'unauthenticated' && !isAuthPage) {
+ navigate('/', { replace: true });
+ }
+ }, [authState]);
+
+ if (authState === 'loading') {
+ return (
+
+ Loading...
+
+ );
+ }
+
+ return children;
+}
+
function App() {
return (
-
- } />
- } />
- } />
-
+
+
+ } />
+ } />
+ } />
+
+
);
}
diff --git a/Frontend/Electron/src/assets/icons/crown.svg b/Frontend/Electron/src/assets/icons/crown.svg
new file mode 100644
index 0000000..84ec03e
--- /dev/null
+++ b/Frontend/Electron/src/assets/icons/crown.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Frontend/Electron/src/assets/icons/invite_user.svg b/Frontend/Electron/src/assets/icons/invite_user.svg
new file mode 100644
index 0000000..3bc48b6
--- /dev/null
+++ b/Frontend/Electron/src/assets/icons/invite_user.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Frontend/Electron/src/components/AvatarCropModal.jsx b/Frontend/Electron/src/components/AvatarCropModal.jsx
new file mode 100644
index 0000000..7b34195
--- /dev/null
+++ b/Frontend/Electron/src/components/AvatarCropModal.jsx
@@ -0,0 +1,134 @@
+import React, { useState, useCallback, useEffect } from 'react';
+import Cropper from 'react-easy-crop';
+
+function getCroppedImg(imageSrc, pixelCrop) {
+ return new Promise((resolve, reject) => {
+ const image = new Image();
+ image.crossOrigin = 'anonymous';
+ image.onload = () => {
+ const canvas = document.createElement('canvas');
+ canvas.width = 256;
+ canvas.height = 256;
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(
+ image,
+ pixelCrop.x, pixelCrop.y, pixelCrop.width, pixelCrop.height,
+ 0, 0, 256, 256
+ );
+ canvas.toBlob((blob) => {
+ if (!blob) return reject(new Error('Canvas toBlob failed'));
+ resolve(blob);
+ }, 'image/png');
+ };
+ image.onerror = reject;
+ image.src = imageSrc;
+ });
+}
+
+const AvatarCropModal = ({ imageUrl, onApply, onCancel }) => {
+ const [crop, setCrop] = useState({ x: 0, y: 0 });
+ const [zoom, setZoom] = useState(1);
+ const [croppedAreaPixels, setCroppedAreaPixels] = useState(null);
+
+ const onCropComplete = useCallback((_croppedArea, croppedPixels) => {
+ setCroppedAreaPixels(croppedPixels);
+ }, []);
+
+ const handleApply = useCallback(async () => {
+ if (!croppedAreaPixels) return;
+ const blob = await getCroppedImg(imageUrl, croppedAreaPixels);
+ onApply(blob);
+ }, [imageUrl, croppedAreaPixels, onApply]);
+
+ useEffect(() => {
+ const handleKey = (e) => {
+ if (e.key === 'Escape') {
+ e.stopPropagation();
+ onCancel();
+ }
+ };
+ window.addEventListener('keydown', handleKey, true);
+ return () => window.removeEventListener('keydown', handleKey, true);
+ }, [onCancel]);
+
+ return (
+ { if (e.target === e.currentTarget) onCancel(); }}>
+
+ {/* Header */}
+
+
+ Edit Image
+
+
+
+
+ {/* Crop area */}
+
+
+
+
+ {/* Zoom slider */}
+
+
+
setZoom(Number(e.target.value))}
+ className="avatar-crop-slider"
+ />
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+ );
+};
+
+export default AvatarCropModal;
diff --git a/Frontend/Electron/src/components/ChatArea.jsx b/Frontend/Electron/src/components/ChatArea.jsx
index d5071e4..8166c70 100644
--- a/Frontend/Electron/src/components/ChatArea.jsx
+++ b/Frontend/Electron/src/components/ChatArea.jsx
@@ -65,8 +65,36 @@ const extractUrls = (text) => {
return text.match(urlRegex) || [];
};
+const VIDEO_EXT_RE = /\.(mp4|webm|ogg|mov)(\?[^\s]*)?$/i;
+const isVideoUrl = (url) => VIDEO_EXT_RE.test(url);
+
+const DirectVideo = ({ src, marginTop = 8 }) => {
+ const ref = useRef(null);
+ const [showControls, setShowControls] = useState(false);
+ const handlePlay = () => {
+ setShowControls(true);
+ if (ref.current) ref.current.play();
+ };
+ return (
+
+
+ {!showControls && (
+
+ ▶
+
+ )}
+
+ );
+};
+
const getYouTubeId = (link) => {
- const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
+ const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|shorts\/|watch\?v=|&v=)([^#&?]*).*/;
const match = link.match(regExp);
return (match && match[2].length === 11) ? match[2] : null;
};
@@ -105,6 +133,16 @@ const isNewDay = (current, previous) => {
|| current.getFullYear() !== previous.getFullYear();
};
+const getProviderClass = (url) => {
+ try {
+ const hostname = new URL(url).hostname.replace(/^www\./, '');
+ if (hostname === 'twitter.com' || hostname === 'x.com') return 'twitter-preview';
+ if (hostname === 'open.spotify.com') return 'spotify-preview';
+ if (hostname === 'reddit.com') return 'reddit-preview';
+ } catch {}
+ return '';
+};
+
const LinkPreview = ({ url }) => {
const [metadata, setMetadata] = useState(metadataCache.get(url) || null);
const [loading, setLoading] = useState(!metadataCache.has(url));
@@ -139,6 +177,11 @@ const LinkPreview = ({ url }) => {
const videoId = getYouTubeId(url);
const isYouTube = !!videoId;
+ const isDirectVideoUrl = isVideoUrl(url);
+
+ if (isDirectVideoUrl) {
+ return ;
+ }
if (loading || !metadata || (!metadata.title && !metadata.image && !metadata.video)) return null;
@@ -173,10 +216,14 @@ const LinkPreview = ({ url }) => {
);
}
+ const providerClass = getProviderClass(url);
+ const isLargeImage = providerClass === 'twitter-preview' || metadata.type === 'article' || metadata.type === 'summary_large_image';
+
return (
-
+
)}
+ {isLargeImage && !isYouTube && metadata.image && (
+
+

+
+ )}
- {metadata.image && (!isYouTube || !playing) && (
-
isYouTube && setPlaying(true)} style={isYouTube ? { cursor: 'pointer' } : {}}>
+ {!isLargeImage && !isYouTube && metadata.image && (
+

- {isYouTube &&
▶
}
+
+ )}
+ {isYouTube && metadata.image && !playing && (
+
setPlaying(true)} style={{ cursor: 'pointer' }}>
+

+
▶
)}
@@ -1042,15 +1099,19 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
const urls = extractUrls(msg.content);
const isOnlyUrl = urls.length === 1 && msg.content.trim() === urls[0];
const isGif = isOnlyUrl && (urls[0].includes('tenor.com') || urls[0].includes('giphy.com') || urls[0].endsWith('.gif'));
+ const isDirectVideo = isOnlyUrl && isVideoUrl(urls[0]);
return (
<>
- {!isGif && (
+ {!isGif && !isDirectVideo && (
url} components={markdownComponents}>
{formatEmojis(formatMentions(msg.content))}
)}
- {urls.map((url, i) =>
)}
+ {isDirectVideo &&
}
+ {urls.filter(u => !(isDirectVideo && u === urls[0])).map((url, i) => (
+
+ ))}
>
);
};
@@ -1263,6 +1324,11 @@ const ChatArea = ({ channelId, channelName, channelType, username, channelKey, u
onBlur={saveSelection}
onMouseUp={saveSelection}
onKeyUp={saveSelection}
+ onPaste={(e) => {
+ e.preventDefault();
+ const text = e.clipboardData.getData('text/plain');
+ document.execCommand('insertText', false, text);
+ }}
onInput={(e) => {
const textContent = e.currentTarget.textContent;
setInput(textContent);
diff --git a/Frontend/Electron/src/components/DMList.jsx b/Frontend/Electron/src/components/DMList.jsx
index 2be9204..6acad46 100644
--- a/Frontend/Electron/src/components/DMList.jsx
+++ b/Frontend/Electron/src/components/DMList.jsx
@@ -3,6 +3,7 @@ import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import Tooltip from './Tooltip';
import Avatar from './Avatar';
+import { useOnlineUsers } from '../contexts/PresenceContext';
const STATUS_COLORS = {
online: '#3ba55c',
@@ -37,6 +38,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
const [searchFocused, setSearchFocused] = useState(false);
const searchRef = useRef(null);
const searchInputRef = useRef(null);
+ const { resolveStatus } = useOnlineUsers();
const convex = useConvex();
@@ -200,7 +202,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
{(dmChannels || []).map(dm => {
const isActive = activeDMChannel?.channel_id === dm.channel_id;
- const status = dm.other_user_status || 'online';
+ const effectiveStatus = resolveStatus(dm.other_user_status, dm.other_user_id);
return (
@@ -222,7 +224,7 @@ const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM }) => {
{dm.other_username}
- {STATUS_LABELS[status] || 'Online'}
+ {STATUS_LABELS[effectiveStatus] || 'Offline'}
diff --git a/Frontend/Electron/src/components/FriendsView.jsx b/Frontend/Electron/src/components/FriendsView.jsx
index 55c3c60..8d26b6f 100644
--- a/Frontend/Electron/src/components/FriendsView.jsx
+++ b/Frontend/Electron/src/components/FriendsView.jsx
@@ -2,12 +2,14 @@ import React, { useState } from 'react';
import { useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import Avatar from './Avatar';
+import { useOnlineUsers } from '../contexts/PresenceContext';
const FriendsView = ({ onOpenDM }) => {
const [activeTab, setActiveTab] = useState('Online');
const [addFriendSearch, setAddFriendSearch] = useState('');
const myId = localStorage.getItem('userId');
+ const { resolveStatus } = useOnlineUsers();
const allUsers = useQuery(api.auth.getPublicKeys) || [];
const users = allUsers.filter(u => u.id !== myId);
@@ -31,7 +33,7 @@ const FriendsView = ({ onOpenDM }) => {
};
const filteredUsers = activeTab === 'Online'
- ? users.filter(u => u.status !== 'offline' && u.status !== 'invisible')
+ ? users.filter(u => resolveStatus(u.status, u.id) !== 'offline')
: activeTab === 'Add Friend'
? users.filter(u => u.username?.toLowerCase().includes(addFriendSearch.toLowerCase()))
: users;
@@ -91,7 +93,9 @@ const FriendsView = ({ onOpenDM }) => {
{/* Friends List */}
- {filteredUsers.map(user => (
+ {filteredUsers.map(user => {
+ const effectiveStatus = resolveStatus(user.status, user.id);
+ return (
@@ -111,7 +115,7 @@ const FriendsView = ({ onOpenDM }) => {
{user.username ?? 'Unknown'}
- {user.status === 'dnd' ? 'Do Not Disturb' : (user.status || 'Online').charAt(0).toUpperCase() + (user.status || 'online').slice(1)}
+ {effectiveStatus === 'dnd' ? 'Do Not Disturb' : effectiveStatus.charAt(0).toUpperCase() + effectiveStatus.slice(1)}
@@ -134,7 +138,8 @@ const FriendsView = ({ onOpenDM }) => {
- ))}
+ );
+ })}
);
diff --git a/Frontend/Electron/src/components/MembersList.jsx b/Frontend/Electron/src/components/MembersList.jsx
index 1f37cbc..cc6da7c 100644
--- a/Frontend/Electron/src/components/MembersList.jsx
+++ b/Frontend/Electron/src/components/MembersList.jsx
@@ -1,6 +1,7 @@
import React from 'react';
import { useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
+import { useOnlineUsers } from '../contexts/PresenceContext';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
@@ -25,11 +26,12 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
api.members.getChannelMembers,
channelId ? { channelId } : "skip"
) || [];
+ const { resolveStatus } = useOnlineUsers();
if (!visible) return null;
- const onlineMembers = members.filter(m => m.status !== 'offline' && m.status !== 'invisible');
- const offlineMembers = members.filter(m => m.status === 'offline' || m.status === 'invisible');
+ const onlineMembers = members.filter(m => resolveStatus(m.status, m.id) !== 'offline');
+ const offlineMembers = members.filter(m => resolveStatus(m.status, m.id) === 'offline');
// Group online members by highest hoisted role
const roleGroups = {};
@@ -54,13 +56,14 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
const renderMember = (member) => {
const topRole = member.roles.length > 0 ? member.roles[0] : null;
const nameColor = topRole && topRole.name !== '@everyone' ? topRole.color : '#fff';
+ const effectiveStatus = resolveStatus(member.status, member.id);
return (
onMemberClick && onMemberClick(member)}
- style={member.status === 'offline' || member.status === 'invisible' ? { opacity: 0.3 } : {}}
+ style={effectiveStatus === 'offline' ? { opacity: 0.3 } : {}}
>
{member.avatarUrl ? (
@@ -80,7 +83,7 @@ const MembersList = ({ channelId, visible, onMemberClick }) => {
)}
diff --git a/Frontend/Electron/src/components/Sidebar.jsx b/Frontend/Electron/src/components/Sidebar.jsx
index adc9d82..844d0ed 100644
--- a/Frontend/Electron/src/components/Sidebar.jsx
+++ b/Frontend/Electron/src/components/Sidebar.jsx
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
-import { useConvex, useMutation } from 'convex/react';
+import { useNavigate } from 'react-router-dom';
+import { useConvex, useMutation, useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import Tooltip from './Tooltip';
import { useVoice } from '../contexts/VoiceContext';
@@ -8,7 +9,7 @@ import ServerSettingsModal from './ServerSettingsModal';
import ScreenShareModal from './ScreenShareModal';
import DMList from './DMList';
import Avatar from './Avatar';
-import ThemeSelector from './ThemeSelector';
+import UserSettings from './UserSettings';
import { Track } from 'livekit-client';
import muteIcon from '../assets/icons/mute.svg';
import mutedIcon from '../assets/icons/muted.svg';
@@ -19,6 +20,7 @@ import voiceIcon from '../assets/icons/voice.svg';
import disconnectIcon from '../assets/icons/disconnect.svg';
import cameraIcon from '../assets/icons/camera.svg';
import screenIcon from '../assets/icons/screen.svg';
+import inviteUserIcon from '../assets/icons/invite_user.svg';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
@@ -101,11 +103,46 @@ const STATUS_OPTIONS = [
];
const UserControlPanel = ({ username, userId }) => {
- const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState } = useVoice();
+ const { isMuted, isDeafened, toggleMute, toggleDeafen, connectionState, disconnectVoice } = useVoice();
const [showStatusMenu, setShowStatusMenu] = useState(false);
- const [showThemeSelector, setShowThemeSelector] = useState(false);
+ const [showUserSettings, setShowUserSettings] = useState(false);
const [currentStatus, setCurrentStatus] = useState('online');
const updateStatusMutation = useMutation(api.auth.updateStatus);
+ const navigate = useNavigate();
+
+ // Fetch stored status preference from server and sync local state
+ const allUsers = useQuery(api.auth.getPublicKeys) || [];
+ const myUser = allUsers.find(u => u.id === userId);
+ React.useEffect(() => {
+ if (myUser) {
+ if (myUser.status && myUser.status !== 'offline') {
+ setCurrentStatus(myUser.status);
+ } else if (!myUser.status || myUser.status === 'offline') {
+ // First login or no preference set yet — default to "online"
+ setCurrentStatus('online');
+ if (userId) {
+ updateStatusMutation({ userId, status: 'online' }).catch(() => {});
+ }
+ }
+ }
+ }, [myUser?.status]);
+
+ const handleLogout = async () => {
+ // Disconnect voice if connected
+ if (connectionState === 'connected') {
+ try { disconnectVoice(); } catch {}
+ }
+ // Clear persisted session
+ if (window.sessionPersistence) {
+ try { await window.sessionPersistence.clear(); } catch {}
+ }
+ // Clear storage (preserve theme)
+ const theme = localStorage.getItem('theme');
+ localStorage.clear();
+ if (theme) localStorage.setItem('theme', theme);
+ sessionStorage.clear();
+ navigate('/');
+ };
const effectiveMute = isMuted || isDeafened;
const statusColor = STATUS_OPTIONS.find(s => s.value === currentStatus)?.color || '#3ba55c';
@@ -191,15 +228,31 @@ const UserControlPanel = ({ username, userId }) => {
-
+
+
+
+
+
- {showThemeSelector &&
setShowThemeSelector(false)} />}
+ {showUserSettings && (
+ setShowUserSettings(false)}
+ userId={userId}
+ username={username}
+ onLogout={handleLogout}
+ />
+ )}
);
};
@@ -445,7 +498,7 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
};
const renderDMView = () => (
-
+
(
-
-
setIsServerSettingsOpen(true)}>
-
Secure Chat
-
▾
+
+
+
setIsServerSettingsOpen(true)}>Secure Chat
+
+
+
-